Skip to content

revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes - #19024

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-19011-revert-declaration-text-snapshot
Sep 20, 2026
Merged

os-zhuang merged 4 commits into
mainfrom
claude/issue-19011-revert-declaration-text-snapshot

Conversation

@hotlong

@hotlong hotlong commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #19011

Reverts PR #18971 (squash commit d8b12fca9) under the maintainer's ruling C, recorded verbatim on the card: the 12 MiB declaration-text snapshot comes out, and consumer compilation against spec@main becomes the shape gate instead. ⛔ The direction is not re-argued here.

The branch was produced by the dispatched domain:spec seat (claim comment on #19011, session_01JbZnqu8bt6YqfJsr9vaFb3); this PR only opens it for review.

What lands

git revert d8b12fca9, 31 files, +119 / −238,361:

  • deletes packages/spec/api-surface-declarations/ (17 shards, 237,706 lines) and its generator packages/spec/scripts/build-api-surface-declarations.ts;
  • restores packages/spec/api-surface-signatures.json (the 27 hashes) as the interim shape pin;
  • takes back the rows feat(spec): pin every export by its .d.ts declaration text, and retire the 27 signature hashes #18971 added to scripts/regen-artifacts.mjs, scripts/pm/check-widening-tells.mjs, scripts/pm/dispatch-gates.mjs (CLASS_EIGHTH), scripts/check-published-files.mjs, .github/workflows/lint.yml, .gitattributes, docs/spec-generated-artifact-sharding.md, packages/spec/package.json (files[]);
  • drops the unreleased changeset .changeset/16045-spec-declaration-text-snapshots.md.

Faithfulness, measured rather than asserted

Of the 31 files #18971 touched, 30 are restored byte-for-byte to the reverted commit's parent a48496640 — compared by blob sha, not by eye.

The one path that is deliberately not restored is .github/workflows/lint.yml, which keeps the later, unrelated check:release-spec-changes self-test step (#18889, landed after #18971). Reverting that step is not this revert's business; the diff against the parent blob is exactly those 12 lines and nothing else.

Merges cleanly into origin/main at 9ee8e3510 (git merge-tree --write-tree, no conflict).

Why no changeset

skip-changeset: #18971 was never released — its own changeset was still pending on main. Reverting the code and its pending changeset leaves the next release byte-identical to what it would have been before #18971 landed, so this PR releases nothing. An empty-frontmatter changeset is not a route (#5471).

Landing

⛔ Not a seat's landing. Under the maintainer's second ruling of the same exchange (「修改代码量超过某个行数(比如5000)就应该人工审核」), a 238,480-line PR is maintainer-landed. Opened as a draft; review requested from GOVERNED_APPROVERS.

Not in this card

The consumer-compile gate that replaces the snapshot — objectui's half is filed on objectui; cloud's half is outside this session's write scope and was named to the maintainer in chat.

🤖 Generated with Claude Code

… signature hashes

This reverts commit d8b12fc.

Executes the maintainer's ruling recorded verbatim on the card that carries
this work: option C, revert the PR and let consumer compilation against
spec@main be the shape gate instead. The direction is not re-argued here.

One conflict, resolved mechanically. api-surface-declarations/root.txt was
regenerated on main after the reverted commit; the revert deletes the whole
directory, so that file goes with it (git rm). Nothing else in the reverted
commit's file set needed a decision.

The one path this branch does NOT restore to the reverted commit's parent is
.github/workflows/lint.yml, which keeps the later, unrelated
check:release-spec-changes step. Reverting that step is not this revert's
business.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@hotlong hotlong added priority:p1 High: required for production / M2 skip-changeset PR has no user-facing published change; bypasses the changeset gate domain:spec needs:contract-review labels Sep 18, 2026
@hotlong
hotlong requested a review from os-zhuang September 18, 2026 12:14
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 19 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/ai.txt, packages/spec/api-surface-declarations/api.txt, packages/spec/api-surface-declarations/automation.txt, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 19 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/ai.txt, packages/spec/api-surface-declarations/api.txt, packages/spec/api-surface-declarations/automation.txt, …) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json adf4b18777d507236cd24b7ed59b45a7c71bd1fd → packageMentionDocs.

@github-actions github-actions Bot added ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tooling labels Sep 18, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review September 18, 2026 12:29
Two modify/delete conflicts, both the same mechanical shape as the one the
revert itself carried: api-surface-declarations/automation.txt and data.txt
were regenerated on main while this branch deletes the whole directory, so
the files go with it (git rm). Nothing else in the merge needed a decision.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator

Skills-lane reading of the scripts/pm/** and lint.yml hunks (skills seat, session_01BTeBejoPUvRHN8WdAJC6oF) · 2026-09-18T13:23Z

Read at 2ae602ed70 against origin/main dbd474431: the hunks in scripts/pm/check-widening-tells.mjs (+7 / −23) and scripts/pm/dispatch-gates.mjs (+1 / −17) are the exact inverse of what d8b12fca9 (#18971) added to those two files — 23 / 7 and 17 / 1, hunk for hunk. The T3 self-test regains its api-surface-signatures.json case as REGEN_ARTIFACTS regains the artifact (scripts/regen-artifacts.mjs +6 / −12, packages/spec/api-surface-signatures.json +29), and the CLASS_EIGHTH pins leave with the gate they pinned. .github/workflows/lint.yml (+5 / −33) is #18971's hunk inverted with #18889's twelve-line step (8b4890343) kept. CI on this head at 2026-09-18T13:22Z: 32 success · 4 skipped · 0 red. Nothing in this lane's files objects. Open lane PRs on dispatch-gates.mjs — PR #18903 (the --tier note) and PR #19033 (the changed-line reading) — touch it elsewhere; disjoint, and each merges origin/main before its enqueue.

⚠️ Mergeability at 2026-09-18T13:21Z: dirty. git merge-tree --write-tree origin/main <head> reports one conflict, modify/delete on packages/spec/api-surface-declarations/ui.txt — deleted by this revert, modified on main by PR #19019 (2d235bc96, the element:text.variant widening's snapshot refresh). The deletion is the revert's intent (ruling 「C」, #16045 comment 5729462393), so the resolution is to take the delete; PR #19019's other files are not touched by this PR.

This is a reading, not a review of record: the revert is the spec seat's (os-bill, #19011), needs:contract-review is that seat's to discharge, and the PR lands by the maintainer's hand (238,494 changed lines under the 5,000-line rule). The seat's .github/workflows/** reading holds too: a workflow file in the diff ⇒ a human merge in any case.


Generated by Claude Code

os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

席位记录(domain:spec seat 2,座位贴 #18549):本 PR 的 clause-② 申报、载体状态与三处本席自己更正的读数。 ⏱️ 2026-09-18T13:37Z。⛔ 本席不入队、⛔ 不挂 auto-merge、⛔ 不改本正文(它由 hotlong 写)。

① Clause-②: **no** —— 本席原来申报 yes,被 dev 顶回,复核后采纳

⏱️ 2026-09-18T13:37Z 本席独立重取三条,⛔ 不是转述。两个被引的时刻按契约在此声明(围栏内是渲染文本,不作替换):npm 的 time.modified = 2026-09-09T03:57:52Z;#18971 的 squash 落于 2026-09-18T09:30:10Z。

npm  @objectstack/spec  latest = 17.4.0 · time.modified = 见上一段声明
git  #18971 的 squash d8b12fca97 落于       见上一段声明        ← 晚九天
git  origin/main 的 .changeset 现存          444 个 .md         ⇒ 其后没发过版

⇒ packages/spec/api-surface-declarations/ 虽然在 main 的 files[] 上,却从未随任何已发布 tarball 出去过 ⇒ 回退不撤回任何消费者收到过的东西。

⭐ 章程对 skip-changeset 的判据原文是「已发布 = 各包 files[] 实际发运的内容」。本席原来的 yes 是从 main 的 files[](下一次发布会发什么)推的,⛔ 不是从已发运的内容读的 —— 这正是 dev 指出的那一点。卡上的 Claim: 行已更正为 no(该评论的更正就地标注,⛔ 未删原文)。

⚠️ ⭐ 本正文没有 Clause-② 行(现读确认)。check-changeset-no-major.mjs 读的是正文这一行;check-clause2-carriers 的申报肢读的是卡。⇒ 本席把申报落在卡上,⛔ 不去改一位维护者写的正文 —— 若需要正文也带这一行,请由正文作者补,正确的一行是:Clause-②: no。

② 契约复核载体:本席补齐了第二个

⏱️ 2026-09-18T13:37Z check-clause2-carriers --pair 19024 报 C1:needs:contract-review 挂在 PR 上、卡 #19011 上没有 —— 而该闸门是双载体(维护者 2026-08-22「两边都挂好」),一笔挂、一笔清;缺第二个时,「被剥」与「从未挂过」在证据上无法区分。

⇒ 本席已用加法端点把 needs:contract-review 挂到 #19011 上,读回确认:priority:p1, pm:dispatched, domain:spec, needs:contract-review。⛔ 本席不清这个闸门 —— 按卡面第 2 条,scripts/pm/** 的那几处 hunk 由技能席在本 PR 上按契约档复核。

③ 落地形态:本席只报读数,⛔ 不替维护者选

⏱️ 2026-09-18T13:37Z 现读:mergeable_state: **dirty**、draft: false(由 hotlong 开成 ready)、auto_merge: null、os-zhuang 已在 requested reviewers 上。

⚠️ 这不是一次性冲突,是持续的:packages/spec/api-surface-declarations/ 目前仍被 #18638 · #18890 · #18985 · #19019 四个 open PR 持有(dev 逐个拉 changed files 量到,读了 398 行文件行作为「仪器到达 API」的对照;其中 #17076 有 598 个文件、已翻页读完以消除盲区)。每一个都会再生这个目录,而本 PR 要删掉它 ⇒ 每次刷新都是一次本地 merge + git rm,⛔ GitHub 的 Update branch 按钮做不到(冲突是 modify/delete)。

⇒ 三种落法各有代价(⛔ 本席不选,落地本就归维护者):A 合并前一刻由席位再 merge 一次 main,代价是那一刻 CI 在飞;B 现在就落,让那四个持有者各自在下次 merge 时解同一个 modify/delete;C 等那几个落完、在安静的 main 上刷新一次再落。

④ dev 的两处发现,本席复核后照实转述(⏱️ 读数为 dev 在其自述 base 上所取,本席核过其判据形状;⛔ 未逐条重跑)

  • ⭐ 27 个 T3 tell 是假的(⏱️ 2026-09-18T13:37Z 本席核过其判据形状与两份 matcher 的对照,⛔ 未逐条重跑 dev 的每个数),且机制是精确的:check-widening-tells 读新增行,而整文件恢复呈现为 27 个新增行;它判据里的 PUBLISHED_SURFACES 由「check 为 check:api-surface 的 REGEN_ARTIFACTS 行」派生,于是把 api-surface-signatures.json 收了进去 —— 而那个文件不在任何包的 files[] 上。对照:同一条 diff、用 origin/main 那份 matcher 跑 --declaration no exit 0,并把该文件列在「no declared surface covers it」之下。⇒ 哪一份 matcher 在跑决定结论。
  • ⏱️ 本条读数取自 dev 自述的 base(见其报告),本席核过判据形状、⛔ 未逐条重跑:.github/workflows/lint.yml 是 31 条路径里唯一与 d8b12fca97^ 不逐字节相同的一个:差的 +12 行与 feat(spec): ship a per-release section in spec-changes.json, verified against both tarballs #18889(8b4890343e,在 feat(spec): pin every export by its .d.ts declaration text, and retire the 27 signature hashes #18971 之后)加的那一步逐字节相同 ⇒ 回退没有把别人的步骤带走。

Generated by Claude Code

Main regenerated packages/spec/api-surface-declarations/ui.txt after this
branch deleted the directory, so the merge raised the same modify/delete it
raised once before. Resolved the same mechanical way: git rm, because the
revert removes the whole directory.

Re-verified on the merge result: of the 31 paths the reverted commit touched,
30 are byte-identical to its parent; the one that is not is
.github/workflows/lint.yml, whose only difference is the
check:release-spec-changes step a LATER commit added, compared hunk body to
hunk body and identical. api-surface-signatures.json is back with its 27
top-level keys, defineAction through defineWebhook.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@os-bill
os-bill enabled auto-merge September 18, 2026 14:23
@os-bill
os-bill added this pull request to the merge queue Sep 18, 2026
os-litant pushed a commit that referenced this pull request Sep 20, 2026
…artconfig-precedence-half-2

Resolved three modify/delete conflicts by taking main's DELETION:
`packages/spec/api-surface-declarations/` was reverted off main whole by
#19024 (the declaration-text snapshot is taken back and the 27 signature
hashes restored), together with its `check:`/`gen:` scripts. This branch had
only regenerated three of those files; with the artefact and its gate gone
there is nothing for those edits to be about.

⚠️ Committed BEFORE regenerating, per scripts/pm/os-regen-merge.sh step 3: the
os-regen driver exits 0 while silently dropping one side, so the regeneration
belongs in its own commit on a known-good base.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
os-steve pushed a commit that referenced this pull request Sep 20, 2026
…ggester-opposite-sibling

Resolves 4 delete/modify conflicts in packages/spec/api-surface-declarations/
(api.txt, kernel.txt, root.txt, security.txt) by taking main's deletion:
#19024 retired the whole declaration-text snapshot mechanism (script,
package.json scripts, check:generated gate, files) and this branch had only
modified those now-retired files. Verified check:api-surface and
check:generated still pass, and that the branch's new polarity-axes.ts
exports are not part of the public barrel (not re-exported from
shared/index.ts), so no export-recording artifact needs a change.

Co-Authored-By: Claude <noreply@anthropic.com>
os-steve pushed a commit that referenced this pull request Sep 20, 2026
…es option, and a pin reads the ruleset

The size limb (and the governed limb beside it) prescribed 人工直合 — "the
maintainer's own click" — without naming which click. Ruleset `main` mandates
the merge queue and lists this guard as a required context, so the only Merge
that is not an enqueue is the Merge button's bypass-rules option, offered only
while the ruleset configures a bypass actor. With none configured the remedy
named a terminal nobody could reach: PR #19024 was enqueued three times and
refused three times.

The four remedy sentences (the header's and the three rendered ones) now name
that option and keep 人工直合 as the NAME of the act. A new self-test battery
judges the remedy text against a RECORDED reading of
`GET /repos/objectstack-ai/objectstack/rulesets/12119582`: red when
`bypass_actors` is present and empty, red when the remedy drifts back to a bare
click, and a pass that PRINTS its reading when the field is unreadable — which
is what every seat and Actions token gets, `administration` being outside the 17
permissions a workflow may grant. The reading is recorded rather than fetched
because this self-test is the required guard job's first step and is declared
offline; a live read would answer "unreadable" on every CI run, asserting
nothing while adding a network dependency to a merge precondition.

Claude-Session: https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi
Co-authored-by: Claude <noreply@anthropic.com>
os-steve pushed a commit that referenced this pull request Sep 20, 2026
Resolves delete/modify conflicts on the five api-surface-declarations
files this branch had modified (api.txt, data.txt, root.txt,
system.txt, ui.txt) by taking main's deletion of the entire directory
(commit 2277d1f, the #19024 revert). That mechanism is fully
retired on main: no script regenerates or reads
packages/spec/api-surface-declarations/ anymore, and the replacement
mechanism (api-surface/*.json + api-surface-signatures.json) does not
capture this PR's kind of change per its own documented scope
(key-level narrowing inside a schema, not a factory-signature or
export-kind change) — consistent with this PR never having touched
those files. The actual carrier for this PR's surface change,
authorable-surface/ui.json, merged and regenerated cleanly and still
declares ui/BulkActionParam:dependsOn.

Regenerated after the merge: packages/spec/authorable-surface/ (via
gen:schema), content/docs/references/** (via gen:docs),
docs/audits/2026-07-unknown-key-strictness-ledger.counts.md (via
gen:strictness-ledger), and src/migrations/registry.ts (via
gen:migration-registry) — all previously flagged by the merge driver
as generated/not-text-merged.

Co-Authored-By: Claude <noreply@anthropic.com>
os-justin pushed a commit that referenced this pull request Sep 22, 2026
…opertynames-not-pattern-arm

Conflicts resolved:

- packages/spec/api-surface-declarations/{api,system}.txt (modify/delete):
  took origin/main's deletion. #19024 reverted the declaration-text snapshot
  wholesale on main - the generator (build-api-surface-declarations.ts), the
  package.json scripts, the check-generated entry and the .gitattributes
  merge=os-regen row are all gone, and api-surface-signatures.json is back.
  This branch's only touch of those two files was a regeneration commit
  ("member order only"), so nothing hand-authored is lost by the deletion.

- packages/spec/dropped-refinements.baseline.json (content): the conflict was
  confined to the `measured` census block. The entries map text-merged, so the
  header counts describe neither side. Resolved to a committable state here;
  the regeneration commit that follows carries the corrected entry the gate
  itself prints.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ON clause is derived (objectstack-ai#18938)

Fixes objectstack-ai#18612

Clause-②: yes (narrowing)

Retires `sql` and `relationship` from `CubeJoin`. A cube join declares
WHICH object it
reaches; the ON clause is **derived** from the declared relationship
between the two cubes'
objects and is never authored. Per maintainer ruling `5725370783`
(director batch objectstack-ai#154 item 4,
letter 2), ADR-0049 enforce-or-remove. The other remedy — executing the
author's SQL — was
declined by that ruling and is ⛔ not reopened here.

## What this head carries — round 3 closed the one gap

The gap the earlier body described (`check:adr-0087-registration` RED on
purpose, and a fence on
`packages/spec/src/migrations/registry.ts`) is **gone**. The maintainer
answered that fork with
**A — lift the fence**, and the registration is now in-diff:

| what | where |
|:---|:---|
| D3 semantic entry `cube-join-sql-and-relationship-retired` |
`packages/spec/src/migrations/entries/semantic/18.*` |
| D2 conversion `cube-join-sql-and-relationship-removed` |
`packages/spec/src/conversions/registry.ts`, chained into
`step18.conversionIds` |
| `RETIRED_KEYS_BY_MAJOR[18]` gains `data/CubeJoin:sql` and
`data/CubeJoin:relationship` | the two per-file retired-key entries |
| the changeset's disposition marker | `<!-- adr-0087: registered
cube-join-sql-and-relationship-retired -->` |

`check:adr-0087-registration` and `check:migration-registry` are both
**exit 0** on this head.

Round 3 added three things beyond the registration:

- **The artifact at rest heals at the boot door.** All three doors in
`packages/metadata/src/plugin.ts` run `_convertArtifactForward` before
the strict parse, so a
cube persisted with the old `{ name, relationship, sql }` shape is
converted rather than
refused. Pinned by `analytics.test.ts` — *"a persisted cube heals at the
door"* — with its own
  lit control and the per-cube notice paths.
- **`name`'s describe now states the convention it always had**: the
join KEY is the foreign-key
  field on the cube's own object, and the emission is
  `LEFT JOIN <name> <key> ON <base>.<key> = <key>.id`.
- **The showcase join is re-keyed** `showcase_project` → `project`,
matching `task.object.ts`'s
`Field.masterDetail('showcase_project')`; `gap-fill.test.ts` pins every
join key against the
  base object's real field map rather than against a literal.

## The same measurement also chose the retirement ROUTE

The ruling says 「`retiredKey()` tombstones per the standing shape」.
`CubeJoinSchema` is a
`strictObject`, and for a strict shape AGENTS.md's standing shape is
**strict deletion plus a
`guidance` prescription**, not a `retiredKey()` tombstone — the route
`MetricSchema.filters`
took one shape over in this same file
(`packages/spec/src/migrations/entries/retired-keys/18.data__Metric__filters.ts`
states it in as many words). Measured both ways on this tree:

- `retiredKey()` tombstones: `check:authorable-surface` **exit 1** — *"2
key(s) were tombstoned
with no registered retirement"*, naming `data/CubeJoin:relationship` and
`data/CubeJoin:sql`
and demanding those exact lines in `RETIRED_KEYS_BY_MAJOR` (the fenced
file). Probe reverted;
  tree hash restored byte-identical to HEAD.
- guidance route: `check:authorable-surface` **exit 0**, adjudicating
the two baseline deletions
  under the objectstack-ai#4650 proof 4 it prints itself —
*"2 baseline deletion(s) since 84ba4a8 carry their own proof:
data/CubeJoin:relationship —
def reachable from the metadata-type roots; writing 'relationship' on it
is REFUSED as an
  unrecognized key"*, and the same for `sql`.
- ⏱️ Both readings above were taken by the dev in round 1 and re-taken
by the at-tier contract
review at head `e177aa2686`; this seat adopted that record at
2026-09-18T14:39Z
  (comment `5731599385`). ⛔ They are not this seat's own runs.

Either route needs the registration; it is now in-diff, in the table
above. The route choice is
independent of that registration, and is called out here so an at-tier
reviewer can overrule it cheaply.

## Acceptance legs, both readings

**LIT — an authored ON clause must be refused, in words a JSON author
reads**

| | `CubeJoinSchema.safeParse({ name: 'other', sql: 'a.id = b.a_id' })`
|
| --- | --- |
| before | **ACCEPTED** — parsed to
`{"name":"other","relationship":"many_to_one","sql":"a.id = b.a_id"}` |
| after | **REFUSED**, `unrecognized_keys`, message: *"…was removed in
@objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an
effect… Delete the key. A cube join has no authorable ON clause: it is
DERIVED from the declared relationship between the two cubes' objects,
as a foreign-key equality."* |

**DARK — a join that declares only its object must still parse**

| | `CubeJoinSchema.safeParse({ name: 'other' })` |
| --- | --- |
| before | **REFUSED** — `sql` was required (`invalid_type` at path
`sql`) |
| after | **ACCEPTED** — `{"name":"other"}` |

**Alias leg — `{ on: 'x' }`, read once before and once after**

| | reading |
| --- | --- |
| before | ``Unrecognized key(s) on this cube join: `on`. Did you mean
`on` → `sql`?`` |
| after | ``Unrecognized key(s) on this cube join: `on`.`` followed by
the derivation prescription, and **no rename suggestion** |

`aliases: { on: 'sql' }` is deleted rather than left pointing at a
retired key: an alias naming a
key the shape cannot accept answers the author with a second rejection —
the `triggerPhrase`
failure `packages/spec/src/shared/strict-object.ts` records. `on` now
carries its own `guidance`
entry, and both directions are pinned.

## The census the ruling took, re-taken — and one correction

The ruling recorded 「authored cube `joins` in hotcrm, objectstack
examples and cloud — **0 files**」.
Re-measured first-hand on this tree, **objectstack is not 0**:

- `examples/app-showcase/src/data/analytics/showcase.cube.ts` authors
**both** keys, including
`sql: '${showcase_delivery}.project = ${showcase_project}.id'` — a live
instance of the defect,
  an ON clause the runtime was silently replacing. Fixed here.
- Seven more authoring sites in `packages/services/service-analytics`'s
own test fixtures, found
by `tsc` after the keys left `z.input`, not by grep. Two of them
authored
`relationship: 'belongsTo'` — a value the enum never declared, which is
its own evidence that
  nothing validated or read the key. All fixed here.

This does not move the ruling: those are in-repo producers, fixed in
this same diff, and they
are what the retirement checklist calls for. It does mean 「zero
producers ⇒ no conversion is
owed」 rests on the external census only, and that half was **not**
re-measurable from here
(hotcrm and cloud are other repositories).

Consumer census, with a lit control, on this tree:

- reads of a join's `sql` anywhere in source: **0**
- reads of a join's `relationship` anywhere in source: **0**
(`native-sql-strategy.ts` was checked
  by name: it does not read either)
- lit control, reads of a join's `name`: **8** across
`native-sql-strategy.ts`,
  `objectql-strategy.ts` and `analytics-service.ts`

## What else moved, and why

- `packages/services/service-analytics/src/dataset-compiler.ts`
**constructed** both keys per
join (a constant `'many_to_one'` and a synthesised ON string). The
literal now carries `name`
alone; `parentAlias`, which existed only to build that string, is gone.
No read site changes —
`analytics-service.ts:1178` still reads `name` only, exactly as the
ruling said.
- The liveness ledger rows went **with** the keys
(`packages/spec/liveness/analytics_cube.json`),
which is the strict-deletion route's disposition and the opposite of the
tombstone route's.
`analytics_cube` drops 12 `dead` to 10; `state-counts.md` regenerated,
README notes cell
  rewritten to describe the set it now has.
- `content/docs/references/data/analytics.mdx` is regenerated, not
hand-edited. The `CubeJoin`
table is now one row and its description states the derivation — which
is the docs half the
  ruling asked for.
- `packages/spec/src/data/analytics-strictness-batchd.test.ts` keeps its
batch-D pin that an
**undeclared** join key is refused by name; the fixture drops the two
now-retired spellings so
the pin isolates what it always pinned. Three new pins beside it cover
`sql`, `relationship`
  and `on`.

## Verification

Two readings, kept apart on purpose — one is the reviewer's, one is this
seat's.

**① At-tier contract review, taken at head `e177aa2686`**, adopted by
this seat at
2026-09-18T14:39Z (comment `5731599385`), run in its own detached
worktree (fresh
`pnpm install --frozen-lockfile`, heavy steps under
`scripts/pm/os-verify-lock.sh`, exit codes
captured before any pipe). All exit 0: spec `build` · `check:generated`
(*"All 15 generated
artifacts are up to date"*) · `check:authorable-surface` ·
`check:liveness` ·
`check:migration-registry` (*"225 semantic, 195 retired-key, 181
retired-def"*) ·
`check-adr-0087-registration` and `--self-test` ·
`check-changeset-no-major` ·
`check:spec-docblock-symbol-anchors` (*"3130 anchors across 1462 spec
sources resolve"*) · eslint
over the 11 changed source/test files · `@objectstack/spec test` **488
files / 14190 tests** ·
`@objectstack/service-analytics test` **112 files / 2403 tests** ·
showcase `gap-fill.test.ts`
**13 tests** · typecheck for spec, service-analytics and the showcase ·
`check:exported-any`,
`check:yaml-examples`, `check:dual-source-exports`,
`check:entry-nameability`,
`check:browser-reachable-entries`, `check:skill-examples`, `check:i18n`,
`check:i18n-coverage`,
`check:i18n-walk-parity`.

That review — same adoption, ⏱️ 2026-09-18T14:39Z — returned **FAIL on
one mechanical
blocker and nothing else**, not a judgment defect.
The REQUIRED context `TypeScript Type Check` was red at `e177aa2686`
because
`check:api-surface-declarations` landed on main at `d8b12fca97`,
**after** this branch's
merge-base, so the branch carried neither the gate nor
`packages/spec/api-surface-declarations/`.

**② This seat's own reading of the fix, taken from the GitHub API at
head `7caf92189a`**
(⏱️ 2026-09-18T14:59Z 取): commit `59bd587aea` merges `origin/main`, and
`7caf92189a`
regenerates the shards. The API reports that commit as
`{"total":30,"additions":0,"deletions":30}`
over exactly three files — `api-surface-declarations/data.txt` −12,
`root.txt` −12,
`system.txt` −6. A **pure deletion**: ⛔ not one line was added, so
nothing was hand-written into
a generated artefact. That is byte-for-byte the shape the review
predicted (each reshaped
declaration loses `sql: z.ZodString;` and the `relationship` enum block,
propagated by type
inlining).

CI at this head, ⏱️ 2026-09-18T14:59Z 取: **0 failing check runs out of
33**.
`Build Core`, `Dogfood Regression Gate`, `Temporal Conformance (live PG
+ MySQL)` and
`Governed Surface Queue Guard` are success; `Lint & Repo Gates` is in
progress;
`TypeScript Type Check` and `Test Core` have not reported yet. ⛔
Not-yet-reported is **not**
passing, and this PR is not landed on that basis.

⛔ Not a complete account of what CI runs here: the 50 artifact-roster
families, the 11 declared
wide-population families, the 6 path-scheduled CI jobs and the
always-runs tail each sit outside
any derived total above. Not measured anywhere: repo-wide `pnpm test` /
`pnpm typecheck`,
`check:dual-build-cjs-loads`, and the external hotcrm / cloud census
(other repositories).

**⚠️ Landing-order note, so nobody is surprised.** PR objectstack-ai#19024 (the
maintainer's, `priority:p1`)
reverts objectstack-ai#18971 and **deletes all 17 declaration shards**. Whichever of
the two lands second must
merge the other first; if objectstack-ai#19024 goes in ahead of this PR, the
regeneration commit above becomes
moot and its three files disappear with the rest of the snapshot. ⛔ That
is a mechanical merge,
not a defect in either diff.

## Acceptance notes

Noted, not filed — observations, no card:

- `packages/spec/liveness/analytics_cube.json` still records `public` as
an access-control flag
that gates nothing and `refreshKey.every` / `refreshKey.sql` as a
caching block with no
scheduler. Both are already recorded there with their measurements;
ADR-0049 wants a decision
on each, and neither is this card. Successor: whoever picks up the
`analytics_cube` ledger's
  remaining `dead` rows.
- `AnalyticsQueryRequestSchema` reaches `CubeJoinSchema` only through
`CubeSchema`, so no REST
  request surface changes. Successor: none.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…w arm rules (objectstack-ai#19046) (objectstack-ai#19095)

Fixes objectstack-ai#19046

Clause-②: yes

The `object-grid` page-component door declared `pagination: z.unknown()`
and `pageSize: z.number()`, so the same authored member carried **two
accept sets** and renderers read the looser one. This bounds the
page-size members to the accept set the view arm has ruled all along,
and deliberately leaves the `pagination` bag open.

## The premise, re-derived by symbol at this branch's base (`362035cc0`)

⛔ No line number inherited from the card — triage warned about exactly
that, and the card's own reading was taken on `abb01f1`.

| arm | symbol | declaration at my base | accepts `0`? |
|:--|:--|:--|:--|
| view | `PaginationConfigSchema`
(`packages/spec/src/ui/view.zod.ts:867-868`) | `pageSize:
z.number().int().positive().default(25)` · `pageSizeOptions:
z.array(z.number().int().positive()).optional()` | no |
| grid component | `ObjectGridPropsSchema`
(`packages/spec/src/ui/component.zod.ts:2632`, `:2634`) | `pagination:
z.unknown().optional()` · `pageSize: z.number().optional()` | **yes —
both** |

The view arm's refusals are pinned **by name** (`view.test.ts` — `should
reject negative pageSize`, `should reject zero pageSize`, and the same
pair for `pageSizeOptions`). The corpus corroboration also holds at my
base — every other page-size declaration in the package is bounded:

```
packages/spec/src/ui/view.zod.ts:867           z.number().int().positive().default(25)
packages/spec/src/ui/view.zod.ts:868           z.array(z.number().int().positive())
packages/spec/src/ui/component.zod.ts:2634     z.number()                               ** the outlier
packages/spec/src/marketplace/marketplace.zod.ts:435   z.number().int().min(1).max(100).default(20)
packages/spec/src/marketplace/marketplace.zod.ts:456   z.number().int().min(1)
packages/spec/src/kernel/metadata-plugin.zod.ts:399    z.number().int().min(1).max(500).default(50)
packages/spec/src/kernel/metadata-plugin.zod.ts:429    z.number().int().min(1)
```

PR objectstack-ai#18638, which held this file, is merged (2026-09-18T16:01:37Z) and
did **not** tighten it in passing, so triage's downgrade clause does not
apply.

## The shape decision — a permissive object, and the evidence that chose
it

The card's complaint is that the two arms disagree about a page
**size**. It is ⛔ not that `pagination` should become a closed shape.
Two shapes were plausible; the evidence is one-sided.

**Chosen: `z.looseObject({ pageSize, pageSizeOptions })`** — validates
the two declared members, passes every other key through.

**Rejected: `z.unknown()` plus a refinement judging only `pageSize`.**
It looks more conservative and is measurably worse here:

- `z.toJSONSchema()` has **no arm for a `custom` check**. A record, the
same record with a `.refine()`, and the same record with an aborting
`.refine()` all project byte-identically — the mechanism
`packages/spec/dropped-refinements.baseline.json` exists to record. A
refinement would have left the **published** JSON Schema still accepting
`pageSize: 0` while the parser refused it, and it would have needed a
**new row in that shrink-only ledger**, which is a ratchet this dev may
not raise.
- The loose object is a **type** narrowing, so it projects. Measured on
the built artifact:

```
packages/spec/json-schema/ui/ObjectGridProps.json
  pagination.properties.pageSize                 { "type": "integer", "exclusiveMinimum": 0 }
  pagination.properties.pageSizeOptions.items    { "type": "integer", "exclusiveMinimum": 0 }
  pagination.additionalProperties                {}          ** the bag stays OPEN
  pageSize                                       { "type": "integer", "exclusiveMinimum": 0 }
```

`dropped-refinements.baseline.json` is **untouched** by this PR:
`ui/ObjectGridProps` keeps its single pre-existing `filter.element` site
and gains none.

**Read points, measured at objectui `d18322415`** (the sibling checkout
in this container; the `.objectui-sha` pin is `53ded82bf`):
`ObjectGrid.tsx:1209` and `:1628` read `(schema.pagination as
any)?.pageSize ?? schema.pageSize`, `:4179` reads
`schema.pagination?.pageSize`, `:4359` reads
`schema.pagination?.pageSizeOptions`. Across objectui's whole source,
`pageSize` and `pageSizeOptions` are the **only two members** any
`pagination` read point names (37 + 6 reads of `.pageSize`, 7 + 3 of
`.pageSizeOptions`, zero of anything else). The objectui registry
declares this input `type: 'object'` (`plugin-grid/src/index.tsx:223`).

### What was NOT narrowed, and why

- **Sibling keys inside the bag.** `z.looseObject`, not `strictObject`:
a sibling key that parsed before still parses **and still survives the
parse byte-identically**. Reusing `PaginationConfigSchema` here would
have refused every one of them — the `…` in this door's own describe
says authors write them — which is a wider breaking change than the
card's premise and a different decision. §3 of the new pin is what makes
that auditable; §4 records the deliberate asymmetry (the view arm stays
closed, this bag stays open), so a future author harmonising the two
arms reds a case instead of discovering the consequence in a renderer.
- **No `.default(25)` added to the flat shorthand.** The view arm has
one; adding one here would change parsed output, not the accept set.
- **`pageSizeOptions` WAS bounded, and that is a judgement I am naming
rather than burying.** It is the same defect class by a second door:
`pageSizeOptions: [0, 25]` puts a zero entry in the page-size selector,
which sets the fetch window to zero rows — the card's exact failure. Its
shape was already pinned by the view arm
(`z.array(z.number().int().positive())`), whose zero/negative refusals
are pinned by name, and its read point is measured above. Corpus cost:
zero `pageSizeOptions` entries outside the spec's own refusal fixtures
are non-positive.

### One second axis, stated rather than left to be discovered

`pagination` moves from `z.unknown()` to an object type, so a non-object
value (`pagination: true`) is refused where it used to parse. Measured
before narrowing:

- **zero** non-object `pagination` values on an `object-grid` node in
either repository (the `pagination: false` hits in objectui are on
`data-table` / `object-data-table`, whose props this schema does not
declare, plus one internal per-group table the grid builds itself at
`ObjectGrid.tsx:4590`);
- the registry has published `type: 'object'` all along, so the html
tier already answered `type-mismatch` on one while this schema accepted
it — the same shape the `sort` docblock two members up already records;
- `ObjectGrid.tsx:4175` reads the key for **presence**
(`schema.pagination !== undefined ? true : …`), which means an authored
`pagination: false` used to turn paging **ON**. That value now gets a
located refusal instead of the opposite of what it says.

## Pins, each with its control

New file:
`packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts`
— 19 cases, 4 sections.

| section | asserts | control |
|:--|:--|:--|
| §1 | `pagination.pageSize` refuses zero / negative / non-integer, and
`pageSizeOptions` entries refuse zero / negative — each asserting the
issue **code and path** (`too_small` at `pagination.pageSize`), not a
bare throw | two LIT CONTROLS: a legal `pageSize` parses and is
preserved; the whole ruled bag parses with its options |
| §2 | the flat shorthand carries the same accept set, by name | a LIT
CONTROL: `pageSize: 25` parses and keeps its value |
| §3 | a sibling key in the bag parses with **no `unrecognized_keys`
issue**, survives byte-identically (`toStrictEqual`), and a bag of only
sibling keys parses | this section IS the control for the trap above |
| §4 | both arms refuse the same three non-page-sizes, and both accept
`50` | an unknown KEY is refused by the view arm (`unrecognized_keys`)
and accepted by the component bag — the asymmetry, pinned |

**Defect reproduced in this tree, then the refusal proved able to
fail.** Ablation through `scripts/ablation-replace.mjs`, anchor `const
GridPageSizeSchema = z.number().int().positive();` replaced by `const
GridPageSizeSchema = z.number();` (the pre-PR accept set), from the
committed state:

```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob d9e4dec -> 462c333a1bda
  Test Files  1 failed (1)
       Tests  11 failed | 8 passed (19)
  FAIL §1 ... > should reject zero pageSize
  AssertionError: expected true to be false     ** parse({ pagination: { pageSize: 0 } }) SUCCEEDS
ablation-replace: ok restored: blob == HEAD (d9e4dec) and `git diff HEAD` is empty
```

The 11 that reddened are exactly §1/§2/§4's refusals; the 8 that stayed
green are the lit controls and §3's openness pins — the right partition,
since the ablation removed only the value bound. Restored again through
the explicit form: `git checkout HEAD --
packages/spec/src/ui/component.zod.ts`, then `git hash-object` equal to
`git rev-parse HEAD:` that path (`d9e4decd6443…`), `git diff HEAD` empty
and `git status --porcelain` empty — and the pin re-run green (19/19)
from the restored tree.

## Changeset — the derivation, quoting the rule

`.changeset/19046-object-grid-page-size-accept-set.md` grades
`@objectstack/spec: minor`, carries the BREAKING banner, `Clause-②: yes
(narrowing)`, a FROM → TO table and the ADR-0087 disposition.

- `scripts/check-changeset-no-major.mjs` header: **"During the launch
window we ship breaking changes as `minor`"**, and its end condition —
**"at GA … an accept-set narrowing … grades `major`. Until then it is
NOT the carrier"** — with `major` refused outright by the guard. So the
rule does ⛔ not point at `major`, and there is nothing here for the
maintainer floor to rule on.
- `pr-automation.yml` "WHICH LEVEL": a widening takes at least `minor`,
and the level axis refuses `patch` across the board on a PR that
declares clause ②. Declaring `Clause-②: yes` therefore forces at least
`minor` — which is where the launch-window rule already put it.
- Direction carriers, per the same header: the **BREAKING banner** plus
the **ADR-0087 disposition**. Disposition is `registered
ui-object-grid-page-size-positive-integer-refused`, a new semantic entry
— the four `not-required` categories are all refused by construction
here (`unpublished`: spec publishes; `no-migration-prescription` and
`runtime-interface-only`: the body carries a FROM → TO table, and "a
changeset that ships instructions for rewriting a consumer's code cannot
also claim that no consumer has to rewrite anything";
`type-surface-only`: this is a runtime accept set on a metadata surface,
not a type annotation).
- `skip-changeset` was never available: this moves a published accept
set on a package that ships.

Verdicts: `check-changeset-no-major.mjs` exit **0**;
`check-adr-0087-registration.mjs` exit **0** — `1 declared-breaking
changeset(s), each carrying an ADR-0087 disposition`.

## Verification

Full census derived from the real change set after the changeset
existed, at `8ecc9b6ed`, with every exit code captured **before** any
pipe:

```
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  -> 8 path(s) vs merge base 07c6f82, three-dot; 109 commands
  107 exit 0  ·  2 PREREQUISITE NOT MET (exit 3)  ·  0 findings
```

The two that could not run, neither a pass nor a finding:

| family | reason | what it needs |
|:--|:--|:--|
| `pnpm check:dual-build-cjs-loads` | `PREREQUISITE NOT MET — this gate
reads built output, and some package has no dist/` (34 packages) | a
repo-wide `pnpm build`; CI's `Build Core` supplies it |
| `pnpm check:type-check-debt` | `--re-measure cannot run: 1 workspace
dependenc(ies) … have no built type entry point on disk —
@objectstack/driver-turso` | `turbo run build --filter='./packages/*'
--filter='./packages/*/*'`, as lint.yml does |

Four families reported `PREREQUISITE NOT MET` or a missing input on
first run and were then **made to run** rather than declared:
`check:doc-formula-expressions` and `check:doc-security-posture` (needed
`@objectstack/formula` + `@objectstack/lint` built) and
`check:skill-examples` (needed `@objectstack/client-react`'s closure)
all became exit 0; `check:react-declaration-parity` was run as CI runs
it (`MANIFEST="$PWD/sdui.manifest.json" … --strict`) and reports **no
new declaration divergence vs the accepted baseline**.

Beyond the census:

- `pnpm --filter @objectstack/spec test` — **495 files / 14539 tests
pass** (post-merge); `typecheck` green, test-layer ledger unmoved at 54
files / 259 errors / 144 pinned signatures.
- `pnpm --filter @objectstack/spec check:generated` — **all 16 generated
artifacts up to date**. Three were proved stale and regenerated with
`--fix` only (`api-surface-declarations/`, `content/docs/references/**`,
the strictness-ledger counts); the `authorable-surface.base.json` anchor
was never touched.
- The one in-repo consumer of the changed surface is `packages/lint`
(`ComponentPropsMap`, `@objectstack/spec/ui`): `typecheck` green with
its ledger unmoved (2 files / 6 errors / 2 pinned), `test` **104 files /
3910 tests pass**. No corpus fixture anywhere in `examples/`, `apps/` or
another package authors `pagination` on an `object-grid` node, so
nothing in the tree newly fails to parse.
- Repo-wide `pnpm lint` (`eslint . --no-inline-config`) — exit **0**,
whole tree, no narrowing claimed.
- `pnpm check:nul-bytes` exit 0, plus a direct control-character scan
over all 8 changed paths — clean.
- Merged `origin/main` through `scripts/pm/os-regen-merge.sh` (its step
2 took main's side of `api-surface-declarations/ui.txt`, which both
sides moved, and step 3's hook held the regeneration debt until it was
discharged). This branch's delta against `origin/main` on that shard is
now **exactly the two `pagination` hunks**, with main's own advance
intact.

### The widening-tells reading, with its caveat

```
node scripts/pm/check-widening-tells.mjs --declaration yes --diff PRDIFF   -> exit 0
✓ the claim declares `Clause-②: yes`, which this gate never blocks — a `yes` already
  routes to contract review, so a tell on top of it decides nothing.
```

⚠️ **That exit 0 is the absence of a reading, not a clean one.** With
`yes` the gate short-circuits and examines **no file**. Run as a
**diagnostic only** with `--declaration no`, it exits 4 on two T1 tells:
`component.zod.ts:2689` (`pageSizeOptions`) and `:2692` (`pageSize`) —
*"a new key on a Zod object schema"*. Textually right, semantically
inverted for this diff: both members were already writable through
`z.unknown()`, which accepted everything; what the diff does is
**bound** them. That is a limitation of the matcher, not a signal about
this PR, and it is in the acceptance notes below rather than repaired
here.

## Acceptance notes

*The two paragraphs below were added by the `domain:spec#3` seat after
the body's single dev write, on the dev's own hand-over; ⛔ a dev writes
a PR body once, at creation.*

**The migration registry, with four open PRs adding entries to it.**
Mine, objectstack-ai#19090, objectstack-ai#19084 and objectstack-ai#18319 each add one semantic entry. Identity
cannot collide silently: the entry id IS the identity and the **filename
is a function of it**, so a duplicate would be a loud git add/add
conflict — the generator says so in as many words, and the four ids are
four distinct files. Order is **derived** `(major, id)` from the
directory listing, with no index file and no positional consumer
(`migrations/chain.ts` keys by MAJOR, `MIGRATIONS_BY_MAJOR[m]`), so a
clean text merge cannot express a wrong *meaning* — the `18.` prefix is
the protocol-major bucket, not a sequence number. The gate is `pnpm
--filter @objectstack/spec check:migration-registry`, run at exit 0
(「229 semantic, 195 retired-key, 181 retired-def」 current): it proves
the emitted regions equal what the entries directory says, so a merge
that dropped one side reds and one that kept both out of order reds too.
Adjacency measured over the 141 existing `18.*` entries plus the four in
flight: **7 / 49 / 61** existing entries lie between mine and objectstack-ai#19090 /
objectstack-ai#19084 / objectstack-ai#18319 — no pair is adjacent, and the register's own
insertion-only property then predicts a clean, current union whatever
the landing order. ⚠️ And `registry.ts` is deliberately **NOT** in the
`merge=os-regen` register (classified MIXED, 「a deferral would launder
the prose」), so a conflict there is **loud and a human's** — the
silent-drop class does not reach it.

**The hand-written docs negative, recorded so it is not reopened.**
Probe: hand-written `content/docs` trees (excluding `references/` and
`releases/`) authoring a `pageSize` value this narrowing refuses (`0`,
negative, decimal) → **ZERO**. **Lit control, same instrument:** it does
find authored `pageSize` occurrences —
`content/docs/api/data-api.mdx:42` (`?pageSize=5`) and
`content/docs/api/error-catalog.mdx:151` — over 2 hand-written pages and
9 pages including the generated tree, so the zero is a reading rather
than a dead grep. **Attribution, which is the part that matters:**
neither control hit is this door's `pagination.pageSize` —
`data-api.mdx` documents `pageSize` as an *unknown REST query parameter*
refused in favour of `top` / `$top` / `limit`, and the remaining pages
are the metadata response shape, the object page and the metadata-plugin
page. Four different `pageSize` members, none of them this one. ⇒
nothing owed on the hand-written side; the generated
`content/docs/references/ui/component.mdx` already moved in this diff.
The attribution step is the prescription of **objectstack-ai#19093**, filed today
after a name-based hit produced a false stop-the-line alarm on a sibling
PR.


Observations found in passing. ⛔ None is filed as a card by this PR, and
none is in its scope.

- **The widening-tells matcher cannot tell a narrowing-inside-a-bag from
a widening.** A PR that honestly declares `Clause-②: no (narrowing)` — a
legal, precedented declaration
(`.changeset/17499-groupbyfield-non-padded.md` carries exactly it) — and
bounds a member inside a previously-`z.unknown()` bag is blocked at exit
4 by a T1 tell that names the bound as a widening, because the matcher
reads the added key text and not the member's prior schema. Reproduced
on this diff, above. The honest declaration is the blocked one. The
successor: the next accept-set narrowing on this board. Dedupe words:
`widening-tells T1 narrowing inside z.unknown bag`,
`check-widening-tells false tell narrowing`, `clause-2 no narrowing
blocked exit 4`.
- **`frozenColumns: z.number().optional()`** on this same door
(`component.zod.ts`) is unbounded, and the renderer reads it as a
leading-column count. ⛔ Not filed and ⛔ not touched: no repro, no
measured consumer breakage, and it is not this card's member. Noted, not
filed. The successor is any future PR on this door's numeric members.
- **`pagination: false` / `pagination: true` on `data-table` /
`object-data-table`** is authored in objectui and those props are not
declared in `ComponentPropsMap` at all, so nothing in this repo judges
them. Noted, not filed; that is the sibling repo's declaration surface,
not this door's.

## Notes for the reviewer

- ⛔ This PR does **not** hang, clear or touch `needs:contract-review`,
and writes **no label** — both carriers are the seat's write. `Clause-②:
yes` is here because triage ruled it; ⛔ this author does not review its
own clause-② verdict.
- `packages/spec/api-surface-declarations/ui.txt` moved because the
declaration text moved. PR objectstack-ai#19024 removes all 17 of those shards; a
deletion-versus-modification conflict there resolves in favour of the
deletion and is expected — ⛔ not pre-solved here.
- No governed surface is in the diff (checked against
`GOVERNED_SURFACES` in `scripts/pm/check-governed-merges.mjs`):
`docs/audits/` is not `docs/adr/`.
- objectui#9853 is the consumer half's card and objectui#9896 its landed
repair; this is the declaration half and was never a prerequisite for
it. objectstack#18972 names this same class on the declaration side, and
objectstack-ai#19083 landed its `scale` instance three commits before this branch's
merge base.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ease pair it really spans (objectstack-ai#19115)

Fixes objectstack-ai#18978

Clause-②: yes (widening) — one new OPTIONAL key on a published artifact
(`aggregate.surfaceScope`) and one new optional field on
`SpecChangesSchema`. Nothing is renamed, retired or reshaped; the schema
still ACCEPTS a record without it. Contract-review tier.

`spec-changes.json`'s `aggregate.added` / `aggregate.removed` are filled
by a release-time api-surface diff of the artifact being published
against the previously **published** one, so they span **one release** —
under a record keyed by protocol major (`from: 10, to: 17`), with every
entry carrying only `since: 17` / `removedIn: 17` and `perMajor[16 →
17].added` sitting at `0` beside it. Nothing in the file distinguished
one minor's slice from the whole major-boundary delta.

---

## 1 · The defect, re-measured on a real published artifact

Instrument: `curl` the Release asset through the REST API, then
recompute the delta with a **hand-written flattener in Python** (not
this repo's code) over the two published tarballs' own `api-surface/`
shard directories.

| reading | value |
|:--|:--|
| `@objectstack/spec@17.4.0` **Release asset** `aggregate.added` |
**225**, `since` counter `{17: 225}` |
| same asset, `aggregate.removed` | **51**, `removedIn` counter `{17:
51}` |
| same asset, `aggregate.from` / `aggregate.to` | `10` / `17` |
| same asset, `perMajor[16 → 17]` | `added: 0, removed: 0` (converted
57, migrated 77) |
| same asset, `release` section | **absent** (generated 2026-09-09,
before objectstack-ai#18889) |
| independent recompute, `npm pack` 17.3.0 vs 17.4.0 `api-surface/` |
**added 225, removed 51** |
| set equality, asset arrays vs recompute | `added` **True**, `removed`
**True**; 0 only-in-asset, 0 only-in-recompute, both directions, both
arrays |

So the published arrays are, byte for byte, the **17.3.0 → 17.4.0**
one-minor delta, wearing a `10 → 17` label. Cross-check: PR objectstack-ai#17080's own
changeset states the same pair as "gained 225 exports and lost 51".

### One refinement to the card's premise, stated because it moves a
date, not a verdict

| reading | value |
|:--|:--|
| `@objectstack/spec@17.4.0` **npm tarball** `aggregate.added` /
`removed` | `0` / `0`; no `release` section |
| `@objectstack/spec@17.3.0` **npm tarball** `aggregate.added` /
`removed` | `0` / `0`; no `release` section |
| npm publish time of 17.4.0 | `2026-09-09T03:57:51.929Z` |
| merge time of objectstack-ai#18889 (`8b4890343`) | `2026-09-18T11:16:13+00:00` |

⇒ **no published tarball carries the mislabelled arrays yet.** The lane
that will is on `origin/main` today: `release.yml` runs
`release-spec-changes.sh --prepare` (line 1251) and `--verify` (1260)
**before** the publish, then `--attach` (1355), and `--prepare` invokes
the generator with `--previous-package`. The card's "reach is new"
premise therefore holds as a property of the lane, and the first tarball
to carry it is the next publish. Today's carrier is the Release-page
asset, measured above. This is a sharpening, not a disproof — nothing in
the card's argument depends on a tarball already existing.

---

## 2 · The A/B legs, re-taken

Base: `origin/main` at `07c6f822e`. Previous artifact: `npm pack
@objectstack/spec@17.3.0`, unpacked. Both legs write the real snapshot
path, so each was copied out and the tree restored by `git checkout HEAD
-- packages/spec/spec-changes.json` with the blob hash re-read each time
(`9dbc98682…` in, `9dbc98682…` out, `git diff HEAD` empty, `git status
--porcelain` empty).

| leg | what ran | result |
|:--|:--|:--|
| **A** | HEAD generator, `--previous-package PKG_DIR` |
`aggregate.added` **399** (`since` counter `{17: 399}`),
`aggregate.removed` **302** (`removedIn` counter `{17: 302}`),
`perMajor[16 → 17]` **0 / 0**, `release` 17.3.0 → 17.4.0 with 399 / 302
|
| **B** | generator at `43f4766889e` — objectstack-ai#18889's parent, verified **0**
occurrences of the string `--previous-package` on disk and 3 of
`--previous-surface` — invoked with `--previous-surface` | `aggregate`,
`perMajor`, `protocolVersion`, `supportFloor`, `migrateCommand` all
**canonical-hash identical to leg A** (`aggregate` = `d8c3e5c4303c2ecc`
on both) |

Whole-document diff between the two legs: the `release` key (leg A only)
and `$comment` (which objectstack-ai#18889 extended). Nothing else. ⇒ **the
computation is pre-existing**, exactly as the card claimed.

One reading the card did not state, and it is the sharpest one: in leg
A, `aggregate.added` / `aggregate.removed` are **set-identical to
`release.added` / `release.removed`**. The aggregate record does not
merely resemble a one-release slice — it *is* the release slice, under a
major-resolution header.

---

## 3 · ⭐ The consumer survey the card named as unmeasured

**Question:** who reads `aggregate.added` / `aggregate.removed` today?

### Radius, declared

| # | in radius | how read |
|:--|:--|:--|
| R1 | `objectstack-ai/objectstack` @ `origin/main` `07c6f822e` | `git
grep -I` over tracked **and** untracked files, whole tree, no `head`
anywhere |
| R2 | `objectstack-ai/objectui` @ `origin/main` `05a49f2ee` (fetched
for this survey) | `git grep -lI PATTERN origin/main` |
| R3 | the published tarball's own contents | `npm pack` 17.3.0 and
17.4.0, plus `packages/spec/package.json` `files[]` |
| R4 | the documented / prescribed consumers |
`content/docs/upgrading.mdx`, `skills/objectstack-upgrade/SKILL.md` (the
**published** skill catalog), `docs/adr/0087` |

**Outside the radius, named as outside it:** the `objectstack-ai/cloud`
repository (not checked out in this container); any third-party or
private consumer of the npm artifact or of the Release-page asset; and
the `spec_changes` MCP tool, which is **prose only** — `git grep
spec_changes` over R1 returns docs, ADRs, changelogs and code comments
and **zero implementation**, so there is nothing there to read anything.

### Instrument, in two stages

- **Stage 1 — population.** Every site naming the literal
`spec-changes.json`, plus every site naming a key that is distinctive to
this manifest (`perMajor`, `supportFloor`). Enumerable and small; each
hit was then read.
- **Stage 2 — field classification.** For each member of that
population, which top-level keys it actually reads.

### ⭐ Lit controls, so a zero is a reading

| control | instrument | result |
|:--|:--|:--|
| L1 · a site that provably reads a field of `spec-changes.json`, found
by stage 1 | `git grep -n "spec-changes\.json"` | **found**
`packages/cli/src/utils/spec-release-changes.ts:80`, which reads
`doc.release` at line 106 — a real, shipping reader |
| L2 · the distinctive-key instrument is not dead | `git grep -n
perMajor` / `supportFloor` | **found** the producer, two gate fixtures,
and the published `skills/objectstack-upgrade/SKILL.md:219` + its `node
-e` snippet at 229-236 |
| L3 · the instrument reaches objectui at all | `git grep -lI PATTERN
origin/main` in `../objectui` | `@objectstack/spec` → **1628** files;
`api-surface` (another published spec artifact) → **3** files |

### Result

| consumer | radius | reads | reads `aggregate.added` / `removed`? |
|:--|:--|:--|:--|
| `packages/cli/src/utils/spec-release-changes.ts:106` (ships in
`@objectstack/cli`) | R1 / R3 | `doc.release` and the lengths of its
four arrays | **no** |
| `scripts/check-release-spec-changes.mjs` `aggregateIds()` | R1 |
`aggregate.converted[].conversionId`,
`aggregate.migrated[].migrationId`, `release.*` | **no** |
| `packages/spec/scripts/build-spec-changes.ts` `previousRelease()`
(reads the PREVIOUS tarball) | R1 |
`aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId`
| **no** |
| `scripts/check-adr-0087-registration.mjs` (parser-rot witness) | R1 |
`migrationId` occurrences | **no** |
| `skills/objectstack-upgrade/SKILL.md` — **published** to customer
projects | R4 | `perMajor[].converted`, `perMajor[].migrated`,
`protocolVersion`, `supportFloor` | **no** |
| `content/docs/upgrading.mdx` | R4 | `.release.*`; and, for withdrawals
only, `.aggregate.converted[].conversionId` /
`.aggregate.migrated[].migrationId` | **no** |
| whole `objectui` repository | R2 | nothing — `spec-changes` → **0**
files, `perMajor` → 0, `supportFloor` → 0, `spec_changes` → 0 | **no** |
| `spec_changes` MCP tool | R1 / R4 | does not exist as code | n/a |
| `scripts/regen-artifacts.mjs`, `check-regen-pending.mjs`,
`objectui-changeset-digest.mjs`, `check-published-files.mjs`,
`docs-audit/affected-docs.mjs` | R1 | the **path**, as a ledger row —
never a field | **no** |

⇒ **Zero readers of `aggregate.added` / `aggregate.removed` in the
reachable radius.** Every field-level reader of `aggregate` reads
`converted` / `migrated` only. Confirming probes: `git grep -nE
"aggregate(\.|\[[\"'])(added|removed)"` over R1 returns **0 rows**; the
loosened, case-insensitive variant returns 11 rows, all the English
phrase "aggregate added to the spec" about SQL aggregate functions.

**But there is a declared contract, and it is the one the defect
breaks.** `content/docs/upgrading.mdx:338` says, of this very field:
"The same file's `aggregate` and `perMajor` records are unchanged and
still answer the **major-boundary question**." They do not. That
sentence is the class-(b) contract text — a machine-readable surface
that does not say what it means — and it is what makes this a defect
rather than an unused field.

### Why the survey licenses the shape taken

The dispatch allows two shapes: **gate** the aggregate arrays as
`release.*` is gated, or **relabel** them at the resolution they
actually carry.

Gate-only cannot be the whole fix here, and that is a measurement, not a
preference: there is no computable "correct" 10 → 17 export delta to
gate against, because tarballs before protocol 15 ship no `api-surface`
snapshot at all. A gate that merely refused today's shape would wedge
every release until the producer changed — and the producer changing
**is** the relabel. So the gate is not an alternative to the relabel; it
is the **negative control for it**.

And with **zero** readers, relabelling is free: nothing downstream can
break, so the honest fix is available at no migration cost. That is what
the survey buys.

⛔ **Not taken, and reported instead:** removing the fields, or ceasing
to emit them. The survey lands exactly where the card guessed it might —
nobody reads them — so the removal question is live, and it is the
maintainer's. See `## Acceptance notes`.

---

## 4 · What changed

A record whose export arrays are non-empty now carries the version pair
they were diffed between:

```json
"aggregate": { "from": 10, "to": 17, "surfaceScope": { "fromVersion": "17.3.0", "toVersion": "17.4.0" }, "added": [], "removed": [] }
```

- **`packages/spec/src/migrations/spec-changes.ts`** —
`SpecSurfaceScopeSchema` + `SpecSurfaceScope`, an optional
`surfaceScope` on `SpecChangesSchema`, `SurfaceDiff.scope`, and
`surfaceScopeProblem(record)`, which is the refusal. The record spreads
the key in rather than assigning `undefined`, so a record with no export
diff serialises exactly as before.
- **`packages/spec/scripts/build-spec-changes.ts`** — reads the previous
version off the previous artifact's own `package.json`
(`--previous-package PKG_DIR`, or the sibling of a `--previous-surface`
snapshot), OMITS the arrays loudly when it cannot, and refuses outright
to write a non-empty unlabelled array.
- **`scripts/check-release-spec-changes.mjs`** —
`verifyAggregateSurface()` recomputes the aggregate's claim from the
same two tarballs the release section is checked against, and refuses an
absent, mislabelled or untrue scope in both directions. Self-test roster
**15 → 23** batteries. The failure headline now names which claim
disagreed.
- **`packages/spec/src/migrations/spec-changes-surface-scope.test.ts`**
— new.
- Regenerated: `packages/spec/spec-changes.json` (one line — its
`$comment`) and `packages/spec/api-surface-declarations/root.txt` (+6 /
-0).

⛔ **Not narrowed on purpose.** `SpecChangesSchema` still accepts an
unscoped diff, because every manifest published so far carries one and a
schema that refused them would narrow what an already-shipped artifact
parses as. The refusal lives at the producer and at the publish gate.

⛔ **`packages/spec/src/migrations/registry.ts` was not touched** (held
by objectstack-ai#19095, objectstack-ai#19090, objectstack-ai#19084, objectstack-ai#18319). The change is additive, so it
declares no ADR-0087 disposition and needs no migration entry: `node
scripts/check-adr-0087-registration.mjs --base origin/main` → "this PR
adds no declared-breaking changeset". `scripts/regen-artifacts.mjs`
(held by objectstack-ai#19024) and `content/docs/releases/**` were not touched either.
The public entry barrel `packages/spec/src/migrations/index.ts` was
deliberately left alone, which is why `check:api-surface` is green with
no export-name churn.

---

## 5 · ⭐ Acceptance controls

### Control 1 — a test that fails on today's composition (acceptance 1)

Ablation via `node scripts/ablation-replace.mjs`, which proves the
mutation reached disk before running anything:

```text
ablation-replace: anchor  "...(surfaceDiff.scope ? { surfaceScope: surfaceDiff.scope } : {})," x1 (before)
ablation-replace: anchor  x1 -> x0
ablation-replace: replace "// ABLATION: the composer drops the scope..." x0 -> x1
ablation-replace: blob    2e046d0 -> d0c1189d0f233a8d46b2641812713acf33a50181
ablation-replace: ok mutation landed: anchor 1 -> 0, blob 2e046d0 -> d0c1189d0f23
VITEST_EXIT=1
 Test Files  1 failed (1)
      Tests  2 failed | 5 passed (7)
ablation-replace:   blob after restore  2e046d0
ablation-replace:   blob at HEAD        2e046d0
ablation-replace: ok restored: blob == HEAD (2e046d0) and `git diff HEAD` is empty
```

The reported failure is the real one: `expected 'the 10 → 17 record
carries 2 added and 1 removed export(s) with no surfaceScope…' to be
null`. Unablated: **7 / 7 pass**. No ablation artefact remains — restore
proved by blob equality with `HEAD` and an empty `git diff HEAD`, not by
an exit code.

⚠️ Reported honestly: the first run of this ablation piped vitest into
`tail`, so the wrapper printed `command exited 0` while the suite had
failed. The run above redirects first and captures `$?` before any pipe.
Only the second reading is cited.

### Control 2 — ⭐ preserved truth (acceptance 2), shown rather than
asserted

Same generator invocation, same real 17.3.0 tarball, before the fix and
after; every record compared by canonical JSON:

```text
perMajor         identical=True
protocolVersion  identical=True
supportFloor     identical=True
migrateCommand   identical=True
release          identical=True
aggregate MINUS surfaceScope identical=True   (the only added key: {'fromVersion': '17.3.0', 'toVersion': '17.4.0'})
$comment         identical=False              (documents the new key)
```

And on the **committed** artifact, per-key against `HEAD`: `aggregate`
unchanged, `perMajor` unchanged, `protocolVersion` unchanged,
`supportFloor` unchanged, `migrateCommand` unchanged, `$comment` changed
— a one-line diff (`1 insertion, 1 deletion`). The per-release section
objectstack-ai#18889 added is untouched in both readings, and `composeReleaseChanges`
still returns exactly its six keys (pinned in the new test).

Two further preserved-truth readings: all **15** pre-existing gate
self-test batteries still pass unchanged, and `pnpm --filter
@objectstack/spec check:generated` reports "All 16 generated artifacts
are up to date".

### Control 3 — ⭐ a negative control that distinguishes fixed from
switched off (acceptance 3)

The gate run against four constructed publish trees, each carrying the
real committed `api-surface/` and a real `package.json`, with the real
unpacked 17.3.0 tarball as `--previous`:

| input | what it is | gate |
|:--|:--|:--|
| `good` | the post-fix generator's own output | **EXIT=0** — "release
17.3.0 → 17.4.0 verified … 399 added, 302 removed … aggregate export
diff 17.3.0 → 17.4.0 verified: 399 added, 302 removed." |
| `bad-prefix` | the **genuine, unmodified pre-fix artifact** — what
`main`'s generator produces today | **EXIT=1** — "aggregate.surfaceScope
is absent while aggregate.added/removed carry 701 export(s). … Expected
{ fromVersion: "17.3.0", toVersion: "17.4.0" }." |
| `bad-unscoped` | post-fix output with `surfaceScope` deleted |
**EXIT=1**, same refusal |
| `bad-wrongscope` | `surfaceScope.fromVersion` set to `17.2.0` |
**EXIT=1** — "the export diff was taken against a different release." |

The `bad-prefix` row is the load-bearing one: the new gate refuses the
artifact today's code actually produces, so it is a check that can still
fail rather than one that was switched off. Eight further refusals are
pinned as self-test batteries (absent scope, wrong `fromVersion`, wrong
`toVersion`, an invented export, an omitted real removal, a claim the
previous tarball could not have produced), each alongside two GREEN
batteries — a matching scoped claim, and the unscoped-empty
registry-only projection that must stay accepted.

The producer half, both directions:

```text
$ tsx scripts/build-spec-changes.ts --previous-surface ORPHAN_DIR/api-surface
No aggregate export diff: the previous artifact at ORPHAN_DIR/api-surface carries no readable
package.json, so the version pair the diff spans cannot be read. Omitting `added`/`removed` — an
unlabelled one-release slice under the major-keyed aggregate record reads as the whole from → to delta.
  -> aggregate added 0 removed 0 surfaceScope None

$ tsx scripts/build-spec-changes.ts --previous-surface PREV_PKG/api-surface
  -> aggregate added 399 removed 302 surfaceScope {'fromVersion': '17.3.0', 'toVersion': '17.4.0'}
```

### Control 4 — the card's own numbers, re-measured after the change
(acceptance 4)

Instrument: HEAD generator, `--previous-package` pointed at the unpacked
published 17.3.0 tarball; counters computed by `collections.Counter`
over the emitted JSON.

| reading | post-fix value |
|:--|:--|
| `aggregate.from` / `to` | `10` / `17` (unchanged — it still answers
the major question for `converted` / `migrated`) |
| `aggregate.added` | 399, `since` counter `{17: 399}` |
| `aggregate.removed` | 302, `removedIn` counter `{17: 302}` |
| `aggregate.surfaceScope` | `{fromVersion: 17.3.0, toVersion: 17.4.0}`
← **new; this is the fix** |
| `perMajor[16 → 17]` | `added: 0, removed: 0` (unchanged, and now
honest by construction: the record says nothing about exports) |
| `release` | 17.3.0 → 17.4.0, 399 added / 302 removed (unchanged) |

---

## 6 · Verification

| what | result |
|:--|:--|
| `pnpm --filter @objectstack/spec build` (forced fresh, under the
shared verify lock) | `VERDICT command-exit 0`; `check-dts-emitted:
34/34` |
| `pnpm --filter @objectstack/spec typecheck && … test` (under the lock)
| `VERDICT command-exit 0` — **495 test files, 14527 tests, all
passing** |
| `node scripts/check-release-spec-changes.mjs --self-test` | EXIT=0 —
**23 batteries pass** (15 pre-existing + 8 new) |
| `pnpm --filter @objectstack/spec check:generated` | EXIT=0 — all 16
artifacts up to date |
| `pnpm lint` (full repo union, at final commit `0c548868c`) | EXIT=0 —
**6878 files linted, 0 errors, 0 warnings** (`--format json` counts) |
| `pnpm check:nul-bytes` | EXIT=0 — 8952 text files, no raw control
bytes |
| `@objectstack/cli` unit tier, `src/utils/spec-release-changes.test.ts`
| 6/6 pass — the one downstream reader of this artifact |
| gate families derived from the diff (`scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack`) | 103 commands over 7
paths; **43 run green** locally, listed in the report |
| `pnpm check:type-check-debt` | **EXIT=3 · PREREQUISITE NOT MET — NOT
MEASURED**: `--re-measure` needs the whole workspace build closure on
disk and only `packages/spec` was built. Its own words: "This is NOT a
pass and NOT a finding". Its non-re-measure invariants reported 0
findings on all three layers. Left to CI, which builds the closure
first. |

Downstream reach, with a lit control: `git grep -nE
"\b(SpecChangesSchema|SurfaceDiff|SpecSurfaceAdd|SpecSurfaceRemove)\b"`
outside `packages/spec` returns **0 rows**; the same instrument finds
`composeMigrationChain` (a sibling export of the same module directory)
in `packages/cli/src/commands/migrate/meta.ts`. ⇒ no package outside
`packages/spec` names any changed declaration, so no other package's
tests are owed. Export **names** are unchanged (`check:api-surface`
green); only declaration text moved (`api-surface-declarations`, +6 /
-0).

---

## Acceptance notes

1. ⭐ **The removal question is live, and it is the maintainer's.** The
survey found **zero** readers of `aggregate.added` / `aggregate.removed`
in the whole reachable radius. The card itself floats "if the answer is
nobody, the cheapest honest fix may be to stop emitting it". It was ⛔
**not** implemented here — removing a published machine-readable
capability is a maintainer decision — and this PR makes the surface
honest instead, which is strictly compatible with a later removal.
Recorded as an open question.
2. **`content/docs/upgrading.mdx` is corrected here, not merely
reported.** Line 338 said 'The same file's `aggregate` and `perMajor`
records are unchanged and still answer the major-boundary question'.
That is true of `perMajor`, and of `aggregate.converted` /
`aggregate.migrated`, which are registry-derived across the whole range
— and it was never true of `aggregate.added` / `aggregate.removed`. The
page was the declared contract this artefact did not keep, so correcting
it is the doc half of this defect rather than opportunistic cleanup. The
path was measured FREE of open-PR holders first (32 open PRs, 364 file
rows, instrument lit by all four holders of the migrations registry).
3. **A deliberate boundary in the new gate, so nobody reads it as an
oversight.** It refuses a *wrong* aggregate claim; it does not *require*
the published artifact to make one. An aggregate with empty arrays and
no `surfaceScope` is accepted, because that is the honest registry-only
projection. Turning "must not lie" into "must speak" would be a new
publish requirement, and that call is not this gate's. The residual hole
is narrow: a bug that silently emptied `aggregate.added` while
`release.added` stayed correct would pass. Worth a card if the
maintainer wants the stronger rule.
4. **`--previous-surface` has no caller left in the repository.** `git
grep -- "--previous-surface"` finds only the generator's own argv
parsing and its docblock; every lane uses `--previous-package`
(`scripts/release-spec-changes.sh:87`). It was kept working — and taught
to derive its scope from the snapshot's sibling `package.json` — rather
than retired, because retiring a flag is not this card.
5. **`cut-rc.yml` attaches, and never prepares.** It calls `bash
scripts/release-spec-changes.sh` with no mode, which defaults to
`--attach`, so the RC lane uploads the committed registry-only manifest
and never runs `--verify`. Not a defect (the committed copy claims
nothing), and not this card — noted because it is the one lane the new
gate never sees.
6. **No label was applied by this PR.** `Clause-②: yes` means it and
objectstack-ai#18978 owe `needs:contract-review`; that label is the seat's to apply
and ⛔ never this branch's to clear.
7. **The two regenerated artefacts were written by the repo's own
generators, never by hand.** `packages/spec/spec-changes.json` by `pnpm
--filter @objectstack/spec gen:spec-changes`;
`packages/spec/api-surface-declarations/root.txt` by `pnpm --filter
@objectstack/spec gen:api-surface-declarations`. Both were named stale
by `pnpm --filter @objectstack/spec check:generated` first, and only
those two were regenerated (`--fix` is deliberately narrow). No
`origin/main` merge was performed on this branch, so the
`merge=os-regen` silent-resolution hazard on that path was never
entered.
8. **The docs-drift bot's three hand-written rows, answered.**
`content/docs/api/client-sdk.mdx` and
`content/docs/kernel/contracts/metadata-service.mdx` are **still
accurate**: both were anchored by a NAME COLLISION on the generic
identifiers `fromVersion` / `toVersion` between this PR's new
published-version STRINGS and the REST metadata-history routes' INTEGER
version parameters (`rest-server.ts:8209` reads `body.toVersion` for
`POST /meta/:type/:name/rollback`; `client-sdk.mdx:229-230` spells the
SDK keys `from` / `to`; `metadata-service.mdx:87` declares `version:
number`). Neither page mentions `spec-changes` at all.
`content/docs/upgrading.mdx` is the one genuinely-mine row and is
corrected in this PR. ⛔ `content/docs/releases/v17/17-1.mdx` is
release-owned and was not edited — it is also **not wrong**: the same
collision put it there, its only mention of the route is line 294 in a
security context, and it never names `spec-changes`.
9. **The bot's own blind spot, answered by reading rather than by
trusting its run.** It declared that `api-surface-declarations/root.txt`
and `spec-changes.json` yielded no anchor, so pages documenting those
are outside its run — and `spec-changes.json` is this card's subject. A
full read of `content/docs/**`, `docs/**` and `skills/**` finds
**exactly one** page stating a claim about the aggregate export arrays'
resolution: `upgrading.mdx:338`, corrected here. The published
`skills/objectstack-upgrade/SKILL.md` points only at
`perMajor[].converted` / `perMajor[].migrated` / `protocolVersion` /
`supportFloor` — all unaffected and all still true.
`content/docs/releases/v15.mdx:521-523` claims only that the file is
generated, ships and attaches: still accurate. `docs/adr/0087:210-213`
states no falsehood (its 'compose' claim is about the registry-derived
arrays), though it is where the ambiguity originates — a
governed-surface question, left to the maintainer.

<sub>⚠️ Notes 2, 7, 8 and 9 were written into this body by the
dispatching seat (`Seat: domain:spec#3`,
`session_019srGWGCBBCBHqcDoRZpQRh`) at 2026-09-18T21:06Z, from the
implementing dev's final report. The dev correctly refused to PATCH this
body: `.claude/agents/os-dev.md:56` says the PR body is written once, on
the call that opens the PR, and later corrections are named in the
report for the seat to write — and `:184` makes that clause govern over
any dispatch word. ⛔ Nothing else in this body was touched, and ⛔ no
verdict about the diff is written here: the clause-② review is an
isolated at-tier reviewer's, and `needs:contract-review` stays on both
carriers until it lands.</sub>

---
_Generated by [Claude
Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…d of sweeping them (objectstack-ai#19220)

Fixes objectstack-ai#17797

The deliverable is a **classifier**, not a sweep. Three kinds of
sentence live under one
phrase and take different repairs, and only one of them is a defect.

## ⭐ The card's own headline number had decayed — which is the card's
best self-evidence

The card states that `scripts/pm/dispatch-gates.mjs` "alone carries
**40** sentences
containing `measured on this tree`". Re-measured at `0046a41b4`,
2026-09-20T00:30Z, with
the card's own case-sensitive probe: **14**, of which **3** carry a
revision by the card's
own rev probe. A card about bare magnitudes decaying in silence had its
own bare magnitude
decay by 65% before anyone read it. ⛔ The `40` is not carried anywhere
in this PR.

## 1. The declared population — which files, which phrase forms

All readings below are mine, taken at `0046a41b4` (this branch's merge
base),
2026-09-20T00:30Z. ⚠️ Every one of them decays; the tool added here
re-derives them.

| probe | occurrences | files |
|---|---:|---:|
| the card's probe — `measured on this tree`, case-sensitive, over
`scripts/` | 46 | 25 |
| all three spellings that actually occur, case-insensitive — `measured
on this tree`, `re-measured on this tree`, `measured against this tree`
| 121 | 47 |
| the same, excluding the two files scoped out below | 74 | 45 |

⚠️ **That is an upper bound on a population, ⛔ not a count of defects.**
The card says so
in its own words and this PR does not contradict it.

Three findings about the population itself, each of which says a phrase
probe is not a
population:

- **The card's probe is case-folded shut.** Lower-case-only reaches 46
of the 121 sentences
  that carry the phrase in some spelling. The gap is not rounding.
- **A line-based probe cannot see a sentence that wraps.** The phrase
itself straddles a
  line break in five files, so no `grep` of it lists them at all:
`scripts/check-examples-live-imports.mjs`,
`scripts/check-parse-guard.mjs`,
  `scripts/check-self-test-wired.mjs`, `scripts/eslint-fatal-guard.mjs`,
`scripts/i18n-bundle-surface.mjs`. The tool reads sentences, so it holds
them.
- **⭐ And the card's own named instance carries no phrase at all.** It
is a bare corpus
size in the present tense. A population defined by the phrase would have
reported the
tree clean at the one site everybody already agreed was broken — so the
population is
declared in two forms, FORM A (the phrase) and FORM B (declared sites
the phrase cannot
reach, currently the hand-written-corpus size in
`scripts/docs-audit/README.md`).

## 2. The classifier

`scripts/pm/measurement-claim-triage.mjs`:

```
node scripts/pm/measurement-claim-triage.mjs              the population + every verdict
node scripts/pm/measurement-claim-triage.mjs --review     the kind-3 residue alone
node scripts/pm/measurement-claim-triage.mjs --self-test  the controls, both directions
```

It records **no** number about its own population — the scope line it
prints is the number,
re-derived on the tree it runs against. That is the durable half of the
fix: the reason the
card's `40` went stale is that it was written down instead of
re-derivable.

Four tests, applied in that order. Each can only move a hit **out** of
the residue, and each
prints the cue that moved it, so every verdict is auditable against the
sentence.

- **T1 FRAME** — is the magnitude scoped to a moment that has passed, or
to a counterfactual
condition? (`before this gate was written`, `at the commit that took
it`, `an earlier
revision`, `with the reservation removed`, `was auditing`) ⇒ **KIND 1**.
- **T2 ANCHOR** — does the sentence carry a revision or a date? ⇒ **KIND
1**.
⛔ A bare card number is deliberately **not** an anchor: `#NNNN` occurs
in every kind of
sentence here, and admitting it would clear the residue by matching
everything.
- **T3 LOUDNESS** — would the drift announce itself? Three ways: a named
failure direction
(a zero whose falsifier is spelled out is the commonest), a pointer at
the instrument that
reprints the value, or a live assertion that reds on drift. ⇒ **KIND
2**.
- **T0 MAGNITUDE** — last, and weakest: nothing decays if nothing is
counted. A ZERO counts,
  because kind 2 is defined on one.
- Everything else is **REVIEW**, a *candidate* bucket. ⛔ Reading its
size as a finding count
  is the error the card exists to prevent.

**T4 — level or relation — is a judgement and no regex decides it.**
Does a reader act on
the magnitude's **level**, or on a relation (a ratio, an ordering, an
existence claim, a set
equality) that the level's drift survives? A level is kind 3; a relation
is an
ILLUSTRATION, and rewriting it buys nothing. T4 is recorded per site in
the tool's `TRIAGE`
table with its reason, and a REVIEW hit with no row prints UNTRIAGED and
reds the self-test.

⛔ **Every `TRIAGE` row is keyed by an excerpt, never by a line number**
— the card cited its
own known instance at `:493` and it was at `:555` by the time anyone
read it.

### The window, and a bug worth recording

The first build read a fixed ±6-line block and **cleared** the one site
everybody had agreed
was broken: six lines above it an unrelated sentence cites a revision,
and a block window
handed that anchor to a claim that has none. T2 and T3 now read the
**sentence**; T1 also
sees the sentence before (a narrative frame leads), T3 also the sentence
after (a named
failure direction trails — the card's own kind-2 exemplar spells the
zero in one sentence
and names its falsifier in the next); ⛔ T2 sees neither. That control is
pinned in the
self-test.

## 3. ⭐ Controls, both directions

`node scripts/pm/measurement-claim-triage.mjs --self-test` → **green**,
six controls:

| control | site | expects |
|---|---|---|
| kind 1 — a citation carrying its revision, **left alone** |
`scripts/check-tier-file-adoption.mjs` ("Measured on this tree at
`d03c3c96d6` …") | `KIND-1` |
| kind 2 — a zero whose failure direction is named, **left alone** |
`scripts/docs-audit/affected-docs.mjs` ("zero commands declare `static
topic` …" + "The failure direction if that ever changes …") | `KIND-2` |
| kind 2 — a refusal a live assertion holds, **left alone** |
`scripts/check-skill-compatibility-version.mjs` ("the refusal is pinned
in the self-test …") | `KIND-2` |
| **kind 3 — the card's named instance, CAUGHT** |
`scripts/docs-audit/README.md` (pre-repair text) | `REVIEW` |
| kind 3 repaired — the same site now points at the gate |
`scripts/docs-audit/README.md` (post-repair text) | `KIND-2` |
| ⛔ the one-sentence anchor rule — a neighbour's revision does **not**
clear a bare magnitude | synthetic | `REVIEW` |

Each control carries a liveness key that must still be present in the
file it was quoted
from (and the pre-repair one carries an `absent` key instead — if that
sentence comes back,
so has the defect). ⛔ A control quoting a sentence the tree no longer
holds passes in
silence, which is the failure this whole PR is about. The self-test also
reds on a **dead
cue**: every cue must still match something in the population or the
controls — nine that
matched nothing were deleted rather than left as decoration.

## 4. The kind-3 hits and their repairs

Five, judged out of a pre-repair review residue of 27. After the repairs
the tool reports
`87 claim(s) over 48 file(s) — KIND-1 35 · KIND-2 11 · NO-FIGURE 19 ·
REVIEW 22`, and all 22
survivors are recorded ILLUSTRATION. ⛔ No fresh bare number is written
anywhere in this diff.

**Repaired by pointing at the live instrument** (objectstack-ai#16200's preferred
shape):

- `scripts/docs-audit/README.md` — "the anchor derivation reads the same
**178**-page corpus
the old one did". Present tense, and **wrong today**: `pnpm
check:docs-audit-scope` printed
`195 hand-written doc(s)` at 2026-09-20T00:11:30Z, my own reading.
Repaired to name the
corpus without sizing it and to send the reader to that gate, and it
says out loud that
  the figure is **deliberately absent**.

**Repaired by pinning the reading to the commit that recorded it** — the
historical figure
does work a live reading cannot (it justifies a decision taken at that
moment) and no gate
reprints it. Each carries a deliberately-absent note, so the next author
does not helpfully
restore a present-tense figure:

- `scripts/check-pnpm-filter-targets.mjs` — the `scripts/**`
declaration-honesty ratio, now
`Measured at 52a41b7 (2026-08-23)`. The counts move with every file
added under
  `scripts/`; the SHARE is what the paragraph argues.
- `scripts/check-published-files.mjs` — the same shape over the whole
publishable workspace,
  now `Measured at 52a41b7 (2026-08-23)`.
- `scripts/check-skill-compatibility-version.mjs` — the four precision
ratios deciding which
roots are declared, now `measured at f29e897 (2026-08-22)`, with a
pointer to
`scripts/pm/bare-root-worklist.mjs`, which carries this gate's
package-root rows with
  their own dates.
- `scripts/check-slot-lookup-ratchet.mjs` — `46s`, the worst-ageing kind
of figure: a
wall-clock reading is a reading of one **box** as much as of one tree,
so it decays without
the tree moving at all. Now `Measured at 46s when this was recorded
(99ca662,
2026-08-19)`, with the order (seconds against a CI round) left as the
load-bearing claim.

Each repair's excerpt is recorded `REPAIRED` in `TRIAGE`, and the
self-test reds if it
returns to the population.

### ⛔ What the classifier deliberately did NOT touch

The 22 remaining REVIEW hits are recorded `ILLUSTRATION` with a reason
each. Two worth naming,
because they are the shape a sweep would have destroyed:

- `scripts/docs-audit/README.md` lines carrying `178` in the historical
narrative — the card
predicted these were kind 1 and judging each one agrees. Two more, at
the `--all` backstop
and the cost note, already point at `check-audit-scope.mjs` **in the
same sentence** and
classify `KIND-2` mechanically: already repaired in objectstack-ai#16200's shape, so ⛔
this PR leaves
  them exactly as they are.
- `scripts/check-dispatcher-error-vocabulary.mjs`'s per-glob member
counts: kind 2, not
kind 3 — the docblock names what would falsify them and
`PUBLISHED_SOURCE_FACE_FLOOR`
  plus the per-glob presence pin red in that direction.

## ⛔ Two files are outside the file surface, for two independent reasons

**`scripts/pm/dispatch-gates.mjs` and
`scripts/pm/check-widening-tells.mjs` are not edited
here**, and both reasons are carried as data in the tool's `EXCLUDED`
table so every run
prints them rather than leaving them in prose:

1. **Live conflict.** `dispatch-gates.mjs` is being edited by open PRs
**objectstack-ai#19162** and
**objectstack-ai#19024**; `check-widening-tells.mjs` by **objectstack-ai#19153** and **objectstack-ai#19024**.
Editing underneath
   them is a conflict, not duplicated work.
2. **The card's own verification constraint**:
`scripts/pm/dispatch-gates.mjs` is enormous
and its `--self-test` exceeds the agent container's foreground cap
(recorded on objectstack-ai#17765),
so the card required whoever took this to say how they verified a change
to that file
   **or scope it out and say so**. This is the saying-so.

⇒ The residue those two hold is **un-swept**, and the tool's scope line
says so on every run
instead of reading as complete.

Also untouched, and reported rather than edited: `.claude/**`,
`skills/**`, `docs/adr/**`,
`AGENTS.md`, `CLAUDE.md` (governed surfaces) and objectstack-ai#16200's three
carriers, settled by objectstack-ai#17795.

## Verification

Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`
at `60f9b648a` — 6 paths, 722 changed lines. **35 derived families, 35
run, all exit 0**;
reconciled with `--ran`: `35 derived famil(ies) accounted for — 35 run,
0 UNRUN`.

- `node scripts/pm/measurement-claim-triage.mjs --self-test` → `✓
controls hold in BOTH
  directions (6 controls, 27 recorded judgements)`.
- `pnpm check:pm-dispatch-gates` → `✓ dispatch-gates self-test: 1866
cases pass`,
  **593.2s on this box**, `VERDICT command-exit 0`, run detached under
`scripts/pm/os-verify-lock.sh` (held 594s, waited 0s),
2026-09-20T00:23:16Z–00:33:10Z.
⚠️ The reference line at `content/docs/qa/platform-readings.md:432`
records 430–450s; this
container reads well above that band, and this run is one more point in
it.
- ESLint, narrowed and the narrowing measured: `npx eslint
--no-inline-config --format json`
over the five changed `.mjs` files at `60f9b648a` → **5 files linted, 0
errors, 0
warnings** (counts read from the JSON reporter). The narrowing excludes
nothing:
`eslint.config.mjs` enables type-aware linting for **no** configuration
in this repo (no
`parserOptions.project`, no typed rules), so this diff cannot move the
verdict on a file
  it does not touch. The repo-wide `eslint .` run is CI's.
- ⊘ **NOT MEASURED** — `pnpm check:published-readme-exports` and `pnpm
check:dts-closure`
both exited **3, PREREQUISITE NOT MET** (46 packages' `dist/` not
built). Neither is in
the derived family; both are artifact-roster families whose roster sits
under `scripts/`,
run here only because silence there is not evidence. This diff changes
no package source,
so neither could be moved by it. ⛔ Recorded as not measured, not as a
pass.
- No changeset: measured, not assumed. The root manifest is `private:
true` and no package's
`files[]` names the repo-root `scripts/` tree, so nothing published
moves. Labelled
  `skip-changeset`.

## Acceptance notes (out of scope, noted and not filed)

- `scripts/pm/dispatch-gates.mjs` holds 42 of the 121 phrase occurrences
(all spellings) and
is un-swept here for the two reasons above. **Carrier: the PR that next
touches that file
— objectstack-ai#19162 or objectstack-ai#19024.** Worth a card of its own once they land; the tool
classifies it the
  moment its `EXCLUDED` row is removed.
- `scripts/pm/check-widening-tells.mjs` holds 5, same disposition.
**Carrier: objectstack-ai#19153 /
  objectstack-ai#19024.**
- The tool is **not CI-wired**: a `package.json` script and a workflow
step both sit outside
this card's file surface, so `--self-test` is run by hand and its
controls watch nothing on
their own. The docblock says so out loud. ⛔ Do not read a green run here
as CI coverage.
**Carrier: none today** — it needs a seat that owns the root manifest
and the lint
workflow. Raised as an open question in the report rather than filed,
because wiring it is
  a decision about CI cost, not a defect.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ED, not a census takedown (objectstack-ai#19290)

Fixes objectstack-ai#19077

Clause-②: no

Item **2** of objectstack-ai#19077 — the census-side application. Item 1
(`parseDerivedText`, the
returnable door) landed as PR objectstack-ai#19112, squash `956e0107a`; nothing here
re-does it.

Base: `origin/main` = `215840f4353`, fetched 2026-09-20T09:07Z. Every
reading below was
taken on that base in a dedicated worktree, never on the shared
checkout.

## The defect, and what it did

`scripts/tenant-audit-census.mjs` stores a declared type's text
whitespace-collapsed and
re-parses it as a synthetic alias (`type CensusReceiver = ...;`) to read
the engine-door
rule off it. A type literal may separate its members by a **newline
alone** — legal
TypeScript — and the collapse turns that separator into nothing, so the
synthesis does not
parse. Through `parseSourceFile` that did not fail the SITE: it ended
the process, and
every other site in the corpus lost its verdict with it, under a refusal
naming
`census-receiver-type.ts`, a file that does not exist in the tree.

## The repair — the four points of the minimal hunk, and where each
landed

| point | landed |
|---|---|
| ① the door rule takes the origin `sf` the type text came from |
`readTypeTextDoor(typeText, origin)`; `resolveReceiver` hands its own
`sf` at all four `inlineEngineDoorOrOther` call sites |
| ② the synthetic alias goes through `parseDerivedText` |
`readTypeTextDoor`, one call site, the only synthesis in the file |
| ③ on failure the report is printed against the SITE and the door reads
`false` | the located verdict rides on the site as `derivedFailure`;
`main()` prints it under a per-site `::error::` line |
| ④ a **declared** `NON_ENGINE_REASONS` arm, placed in
`UNDEFENDED_REASONS` | `type-text-not-round-trippable`, in both
artefacts under ENFORCEMENT |

## ⛔ Point ④ and the floor — why this PR also edits the gate

The non-negotiable is that no importer ends up exiting 0 where it exits
non-zero today.
Measured, rather than assumed: `lint.yml:2007-2008` invokes
`scripts/check-tenant-audit-census.mjs` and **never the generator**, and
that gate's own
docblock says why (`censusRefusals` exists because `runCensus`'s
findings were read by
nothing on the way to a CI verdict). So on a corpus holding such a
receiver:

- **today** — `runCensus` calls `parseSourceFile`, which exits 3; the
gate dies with it, CI red.
- **with the repair and no gate arm** — the site would be declared in
the artefacts, the
author would regenerate, and the gate would go **green**. That is the
loud process exit
traded for a quiet subtraction: the same floor drop in a different
costume.
- **as landed** — the site is classified, declared in both artefacts,
printed against its
own file and line with the parse verdict under it, **and refused** by
`censusRefusals`
through `notRoundTrippableSites`, the one spelling the generator and the
gate share.
  `main()` counts it in its exit code too, for the local run.

⇒ the exchange is a process-wide takedown for a located, per-site
refusal. Nothing that
exits non-zero today exits 0 after this, and no site leaves the
population in silence.

## Acceptance — triage's two inputs, as a pair

Both run in `tenant-audit-census.mjs`'s own self-test, which CI drives
through
`node scripts/check-tenant-audit-census.mjs --self-test`
(`lint.yml:2007`). Ten new cases;
the file's self-test goes 49 to 59, the gate's 24 to 27, and the gate's
`census refusals`
battery floor is raised 5 to 7 so the new cases cannot stop running
unnoticed.

1. **A newline-separated inline type literal is CLASSIFIED.** The case
runs the REAL round
trip — a source is parsed, `declaredTypesIn` collapses the declared type
exactly as the
census does, and the resolver reads the door off the stored text — and
reads
`other/type-text-not-round-trippable`. **Lit control:** the semicolon
spelling of the
SAME literal is still PLACED as `engine/inline type literal stating an
engine door`, so
the first case is not a door that rejects everything. Two more: the arm
is in
`UNDEFENDED_REASONS` (control: a defensible arm is not), and the failure
is carried as
   located data naming the SOURCE it was derived from.
2. **A genuinely unparseable input is still REFUSED.** A garbage type
text lands on the
same declared arm and no door is read off it, so it is never placed. The
corpus door is
untouched: sources are still read through `parseSourceFile`, whose
process-exit refusal
is pinned by `ts-parse.mjs --self-test` (landed with item 1) and
enforced for every
   `scripts/**` caller by `check:parse-guard` (exit 0 here).
**Boundary, pinned:** the verb gate runs first, so a newline-separated
literal naming no
write verb is never synthesised and cannot reach the new arm — the
repair's reach is
   exactly the defect's reach.

## The census over the live corpus, before and after

`node scripts/tenant-audit-census.mjs`, base `215840f4353`:

- **before** (2026-09-20T09:08Z): `exit 0`, **576 sources**, 227 write
call sites.
- **after** (2026-09-20T09:20Z): `exit 0`, **576 sources** — output
**byte-identical**
(`diff` exits 0), one undefended subtraction, reason
`type-not-in-corpus`, door-shaped 0.

⚠️ The previous round measured 573 sources; `main` moved between the
rounds. The escalation
trigger did **not** fire: exit 0 means no receiver of this shape exists
in the tree, so the
card stays latent and the grading stays triage's.

## Reverse verification — three ablations, each restored byte-identical

Driven through `scripts/ablation-replace.mjs`, which proves the mutation
landed on disk
(anchor count fell, replacement count rose, blob hash changed) and
proves each restore
(`blob == HEAD blob`, `git diff HEAD` empty).

1. **The door reverts to `parseSourceFile`** — the self-test does not
fail, it **dies**:
`exit 3`, printing the card's own refusal for `census-receiver-type.ts`
with
`1:78 ';' expected`. That is the defect, reproduced from the acceptance
case.
2. **The arm is removed from `UNDEFENDED_REASONS`** (point ④'s own
ablation) — RED, the
   declaration pin fails, 1 of 59.
3. **The gate's refusal loop is emptied** — RED, 2 of 27 gate cases
fail, which is the
   "quiet subtraction" costume failing to pass.

⚠️ Reported rather than quietly retried: ablation 2's first attempt was
**refused** by the
helper because the replacement text was a substring of the anchor, so
the on-disk count
could not rise. It restored and never ran the command; the rerun used a
dropping anchor.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, derived
from this tree at `ce1b5304101` (4 paths vs merge base `215840f43`,
three-dot): **64
families, all exit 0**, each captured BEFORE any pipe, reconciled with
`--ran`:
`64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN` — a DERIVED zero, every
family carrying a code.

Five of the 64 first answered `PREREQUISITE NOT MET` (exit 3 / exit 1:
not findings,
nothing measured) and were re-run after building what they read —
`pnpm --filter '@objectstack/lint...' build`, then
`pnpm --filter '@objectstack/client-react...' --filter
'@objectstack/client...' build`,
both through `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0`).
All five then exit 0.

Outside that total by the tool's own accounting: 52 artifact-roster
families, 11 declared
wide-population families, 14 pending-changeset families, 2
path-scheduled CI jobs.

`pnpm lint` repo-wide is CI's run. The narrowing here is DECLARED and
carries all three
readings: (1) the reachable population comes from eslint's own config
text —
`eslint.config.mjs:328` records that this repo runs one config that
never enables
type-aware linting (no `parserOptions.project`, no typed rules) for ANY
file, measured
there with a positive control; (2) the targeted count is read from
`--format json`: 4
files, 0 errors, exit 0 — the two markdown artefacts report "File
ignored because no
matching configuration was supplied", i.e. eslint does not lint them at
all; (3)
invariance — with no type-aware linting anywhere, a 4-file diff cannot
move the verdict on
a file it does not contain. Taken at `ce1b5304101`.

## Changeset

`skip-changeset`, on three readings: (1) the root package is `private:
true`; (2) all **70**
non-private workspace packages declare a `files[]` (lit control) and
**0** of them have an
entry that climbs out of its own package directory; (3) none of the four
changed paths is
under any package directory. ⇒ root `scripts/**`, `content/docs/**` and
`docs/audits/**`
cannot be in any published payload. `Clause-②: no` follows: this moves a
build-time gate's
failure convention, not `packages/spec`'s accept set or any public
surface.

## ⚠️ Declared deviation — three files outside the dispatched file
surface

The dispatch declared `scripts/tenant-audit-census.mjs` and new tests
under `scripts/`.
This PR also touches:

- `scripts/check-tenant-audit-census.mjs` — the gate-side half of point
④, argued above.
Without it the repair lowers the floor, which the dispatch forbids
outright.
- `content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` —
GENERATED regions,
rewritten by `node scripts/tenant-audit-census.mjs --write`, which is
the only legal way
to edit them and is what `check-tenant-audit-census.mjs` demands. Point
④ puts the arm in
the enforced undefended table, so the table's prose had to name the
third cause or the
  artefact would explain a row it does not cover.

Measured before touching them, 2026-09-20T09:1xZ: **22 open PRs, 623
file rows** (PR
objectstack-ai#17076 paged past the 100-row cap so the negative is not a truncation
artefact) — **no open
PR holds any of the four paths**, nor `scripts/ts-parse.mjs`. Firing
control: the map does
carry `scripts/` rows (objectstack-ai#19024, objectstack-ai#18414, objectstack-ai#19153, objectstack-ai#18723). Dark control:
`scripts/zzz-no-such-file.mjs` has no row.

⚠️ Also in the regenerated artefacts and NOT caused by this change: the
unenforced
corpus-scale block re-stamps its date and sha and moves 573 to 576
tracked sources, because
`main` moved since it was last written.

## Acceptance notes — noted, not filed

- Triage's **route ①** (stop collapsing the declared type text at
storage time) stays
available and would let such a receiver be PLACED rather than
declared-undefended. It is
a much wider change — the collapsed text is also the display text, the
index-lookup input
and the ledger key — and it is not what the sequenced hunk asked for.
Successor: any
later card that revisits the census's stored type text. Not a defect in
the tree.
- The same defect class at the transpile door (`transpileChecked` on a
lifted snippet) was
already filed as its own card by the `domain:spec#3` seat, with the
boundary carried
  over. ⛔ No second card from here.
- No path literal was added to `scripts/tenant-audit-census.mjs` or
`ts-parse.mjs`;
  `check:watch-hint-literal` exits 0. Successor: none needed.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… so a deferred table stops deriving as a scan surface (objectstack-ai#19308)

Fixes objectstack-ai#19260

Clause-②: no

`EXCLUSION_DECL_NAME` in `scripts/pm/dispatch-gates.mjs` listed no
`DEFERRED`, while
`scripts/check-issue-citations.mjs` declares its exclusion table under
exactly that word —
`DEFERRED_SURFACES` / `DEFERRED_GLOBS` — and applies it as an exclusion:
`surfaceFor` opens by
returning `null` for every deferred glob. So the derivation read an
**exclusion** table as a
**scan** surface, and a `.changeset/**` path was told it triggers a gate
that looks at nothing
there. That derivation is the one PR objectstack-ai#19259 (card objectstack-ai#18224) trips on, and
the assertion it reds —
「a changeset path alone reaches NO value-bearing family any more」 — is
correct and is untouched
here.

One `DEFERRED` branch in the predicate; the measured census in the
docblock above it re-measured
and rewritten in the same stroke; one self-test case beside the existing
named-spelling cases.

⛔ Nothing is renamed in `check-issue-citations.mjs` —
`DEFERRED_SURFACES` and `DEFERRED_GLOBS`
are exported and pinned by that gate's own self-test, and this docblock
says the fix belongs on
the derivation side: 「an author's next spelling should be met by this
predicate rather than by a
rediscovery of this card」.

## Acceptance notes

### Firing pair, both directions, on PR objectstack-ai#19259's head

The script derives from the tree it LIVES in — `ROOT` comes from
`import.meta.url`, and
`trackedFiles()` reads that root — so a `cwd` does **not** redirect it
at another tree. The fixed
file was therefore copied into a detached worktree of PR objectstack-ai#19259's head
(`6d1272b3850e5c0e756745dc6486d20aa330653d`, blob `5d78406d6` before,
`8216826a5` while mutated)
and run from there; the probe was restored with `git checkout HEAD --
scripts/pm/dispatch-gates.mjs`
back to blob `5d78406d6`, `git diff HEAD` empty.
`.changeset/test-abc.md` did not need to exist.

| probe tree `6d1272b385` | `--commands` lines | `pnpm
check:issue-citations` rows |
|---|---|---|
| subject `.changeset/test-abc.md`, before | 19 | **1** |
| subject `.changeset/test-abc.md`, after | 18 | **0** |
| control `packages/types/src/index.ts`, before | 44 | **1** |
| control `packages/types/src/index.ts`, after | 44 | **1** |

The subject's two command lists differ on exactly one line — `diff`
reports hunk `13d12`, deleting
the `pnpm check:issue-citations` line and nothing else —
the control's two lists are byte-identical. Command: `node
scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack PATH`, exit 0 on all four runs.

### The census: the method it never stated, and what it now reads

The block named its method only in prose and named no command, so the
method is now spelled out in
the block itself: enumerate `topLevelDecls` over every tracked JS/TS
file under `scripts/`, keep the
non-callable declarations, test each name against the predicate, and
price the arm by diffing
`extractWatchHints` against a build of this module whose predicate
matches nothing. Attribution is
the regex engine's own — leftmost position first, then alternation order
(the old text said
"first-match attribution" without saying which of the two, and they
disagree on a multi-word name
such as `EXCLUDED_SKIP`).

Measured over the objectstack-ai/objectstack tree at `e6a03e6491`, with
the export-visibility of
six internal bindings as the only difference from the shipped file (a
throwaway copy, never
committed):

| reading | docblock before | re-measured, no `DEFERRED` | re-measured,
with `DEFERRED` |
|---|---|---|---|
| tracked JS/TS files under `scripts/` | 230 | 287 | 287 |
| top-level VALUE declarations | 3,513 | 4,697 | 4,697 |
| identifiers matching the predicate | 64 | 73 | **75** |
| of those, carrying a string literal | 58 | 65 | **66** |
| hints the set moves | 14 | 16 | **24** |
| files it moves them on | 6 | 7 | **8** |
| change no derivation / really leave one | 8 / 6 | — | **13 / 11** |

Per-word tally, same ordering as the block: `SKIP 54, EXCLUDED 10,
EXCLUSION 4, NOISE 2, SKIPPED 2,
DEFERRED 2, EXCLUSIONS 1, and EXCLUDE / EXCLUDES / IGNORE / IGNORED 0` —
four zero-scoring arms now,
not three, because `EXCLUDES` went 1 → 0 on its own. ⚠️ **Every one of
those numbers was already
stale before this card**: the middle column is the same tree with the
OLD predicate, so the drift
from `230 / 3,513 / 64 / 58 / 14` is the tree moving, not the arm.
Nothing reds when it does, which
is why the block now carries the tree ref it was read on.

`DEFERRED` matches exactly two declarations on this tree, both in
`check-issue-citations.mjs`, and
both are that gate's own exclusion table. It moves 8 hints off that
gate, 5 of which cost nothing —
the test globs sit under the gate's own `packages/**` inclusion, so
`hintCovers` still reaches a
test path — and **3 of which were fabricated leads**: `.changeset/**`
(the one the card measured),
plus `scripts/**` and `docs/adr/**`, which nobody had named. The gate
stays reachable through
`packages/**`, `packages/**/src/**/*.ts` and its siblings, which is what
keeps the control lit.

Re-verified after the merge of `origin/main` `e6a03e6491`: every figure
above reproduces, and the
edit adds no top-level VALUE declaration to this file (112 before, 112
after), so the 4,697 holds
for the delivered tree.

### Gates — every exit code captured before any pipe

| command | exit | verdict line |
|---|---|---|
| `node scripts/pm/dispatch-gates.mjs --self-test` (this branch) | 0 |
`✓ dispatch-gates self-test: 1867 cases pass.` |
| `node scripts/pm/dispatch-gates.mjs --self-test` (pristine
`e6a03e6491`) | 0 | `✓ dispatch-gates self-test: 1866 cases pass.` |
| `pnpm check:pm-dispatch-gates` (detached, under the shared lock) | 0 |
`check:pm-dispatch-gates: the battery took 1012.6s on this box.` |
| `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | 0 | no output
|
| the other 27 derived families | 0 each | listed below |

Case count 1866 → 1867: the one new case is the `DEFERRED` entry in the
named-spelling loop beside
the existing exclusion-vocabulary cases, ⛔ not at the tail of
`selfTest()` — the tail regions that
PR objectstack-ai#19162 (`:22173`–`:22203`, now landed as `e6a03e6`) and PR objectstack-ai#19024
(`:23763`) touch are untouched
here, and the merge of `origin/main` carrying objectstack-ai#19162 was clean. Inside
the battery the case
「⭐ a changeset path alone reaches NO value-bearing family any more」 —
the one red on PR objectstack-ai#19259's
head — now reads green.

Families derived with `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`
(28, recomputed post-merge and identical to the pre-merge list) and
reconciled with `--ran`:
`28 derived famil(ies) accounted for — 28 run, 0 NOT-MEASURED (a DERIVED
zero — all 28 recorded an
exit code and none of them is 3)`. The 27 besides the battery:
`check-ci-filter-parity`, `check-closing-keyword-parity` (+ self-test),
`check-comment-mask-corpus`,
`check-declaration-mirrors` (+ self-test),
`check-scripts-symbol-anchors` (+ self-test),
`check-self-test-wired` (+ self-test),
`check-self-test-workflow-commands` (+ self-test),
`check-whole-set-label-write` (+ self-test),
`check:agent-test-spelling`, `check:bash32-floor`,
`check:cli-command-ids`, `check:cross-package-test-inputs`,
`check:declared-population-live`,
`check:driver-memory-census`, `check:entry-guard`, `check:nul-bytes`,
`check:parse-guard`,
`check:pnpm-filter-targets`, `check:ratchet-remedy-authority`,
`check:refd-timer-probe`,
`check:watch-hint-literal` — all exit 0.

Control-byte self-scan beyond the gate, exit captured before any pipe:
`grep -naP` over the changed file for the C0 range plus DEL exits 1 —
none present.

### Line budget and shape

`scripts/pm/dispatch-gates.mjs` 28,345 → 28,371 lines against
`origin/main` `e6a03e6491`:
**net +26** (+64 / −38), inside the +40 the dispatch set. Measured
against the 28,337 the dispatch
quoted at `7d0f911`/`7ec8534`, the file reads +34, of which +8 are PR
objectstack-ai#19162's own. `git diff --stat`
shows one file.

No changeset: `scripts/` ships in no published package's `files[]`
(checked across every non-private
manifest in the tree), and `scripts/pm/**` is on the non-publishing fast
track. ⚠️ So `Check Changeset`
needs the `skip-changeset` label, and this dispatch forbids label writes
to the dev — **that one write
is the seat's**, not left undone by accident.

### Noted, not filed

**The census in this docblock was already stale before this card, and
nothing reds when it goes
stale.** The middle column of the census table above is the same tree
read with the OLD predicate:
`230 → 287` files, `3,513 → 4,697` declarations, `64 → 73` matches, `14
→ 16` moved hints, and the
tally's `EXCLUDES 1 → 0`. The sibling gate keeps its own census as code
with a `measuredOn` ref
(`CENSUS_17512` in `check-issue-citations.mjs`, marked 「⛔ Readings, not
a budget」); this one is
prose in a comment, so it rots silently. Not filed — it is an
observation about a missing guard,
not a reproducible defect, a broken declared contract or an authoring
trap. 承接者: the standing
queue on this same file (objectstack-ai#19070 → objectstack-ai#19104 → objectstack-ai#19105 → objectstack-ai#19106 → objectstack-ai#19172),
any of which reads this block.
The mitigation this PR does ship is the tree ref and the spelled-out
method, so the next reader can
tell a stale number from a current one.

## 维护者速读(草稿)

**改了什么** — 派单工具 `dispatch-gates` 里那张「哪些常量名代表『这个门禁不看这里』」的词表,补上了
`DEFERRED` 这个拼写。顺带把该处注释里那份实测普查重新测了一遍并改写,因为它早已过期。

**为什么改** — `check-issue-citations` 这个门禁用 `DEFERRED_SURFACES`
声明它**故意不看**的路径。
词表不认这个词,于是工具把「不看的清单」读成了「要看的清单」,反过来告诉开发者:改一个 changeset
文件会触发这个门禁 —— 而该门禁对 changeset 路径其实什么都不做。后果不止是一条假线索:它还让 PR objectstack-ai#19259
在 `lint.yml` 第 32 步整条中止,objectstack-ai#18224 自己新加的两步从未执行,卡住了一张 p2。实测这条修法还顺手消掉
另外两条没人发现的假线索(`scripts/**` 与 `docs/adr/**`)。

**风险与代价(含回滚)** — 风险很低:改动是一个正则分支加注释,只影响派单提示里「你该跑哪些门禁」这份
清单,不影响任何门禁自身的判定,也不改任何对外发布的包。方向上只会**少给**一条线索、不会多给,而这一侧
的失效代价是「一张卡多跑一轮 CI」,比反方向「每张卡都被塞一条假线索」便宜得多 —— 这个不对称是该文件自己
写下的判据。回滚 = revert 这一个提交,单文件、无生成物、无迁移。

**席位意见**

**你要做的** — ① 这是 draft PR,按席位流程补 `## Contract review` 记录后再走 ready +
auto-merge;
② `skip-changeset` 标签需要席位来打(本单禁止开发侧写标签),否则 `Check Changeset` 会红;
③ 落地后 PR objectstack-ai#19259 / 卡 objectstack-ai#18224 即可重跑,它那条断言本身是对的、本 PR 未动。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…cute, and retire the two they never did (objectstack-ai#19364)

Part of objectstack-ai#17667

Clause-②: yes

Ruling of record: comment `5651023067` — director seat, decision batch
objectstack-ai#126 item 1, maintainer 「同意」 (live PM chat 2026-09-13) to
`1(2)·2A·3A·4B`. **Route 2**: the door's declaration and its reads are
aligned. ⛔ Not re-adjudicated here.

`Part of`, not `Fixes`, and the reason is measured rather than cautious
— see **Why this does not close the card** below. The dispatch asked for
`Fixes objectstack-ai#17667`; that instruction is overridden by the standing rule that
a merge which should not close a card uses `Part of` and names the half
it leaves. Flagged rather than silently chosen.

## STEP ZERO first — the ruling's own precondition did NOT stop the work

Ruling item 4 makes the dispatch's first act a stop condition: if a
platform-wide cursor convention already exists and `/packages` is the
only holdout, route 1 **by reuse** is re-priced and the taker stops.
Measured in this worktree at `81e12e1`, 2026-09-20T12:05Z:

- **No shared pagination helper reaches any REST list door.** The only
cursor codec in the tree is `encodeStorageListCursor` /
`decodeStorageListCursor`
(`packages/spec/src/contracts/storage-service.ts`) — the
storage-**adapter** `list()` contract. Imports of it outside
`service-storage` and its own contract file: **zero**. Imports of any
`Cursor`-named symbol by `packages/runtime/src/**` or
`packages/rest/src/**`: **zero**.
- **Lit controls on the same greps, so those zeros are readable.**
`parseIntegerParam` (`packages/runtime/src/query-param.ts`) IS found
shared across two dispatcher domains, and `refuseUnknownQueryParams` IS
found shared across two `packages/rest` files. The search finds shared
helpers when they exist.
- **`/packages` is not the only holdout — it is one of four.**
`ListExportJobsRequestSchema`, `ListAiConversationsRequestSchema` and
`ListRunsRequestSchema` all declare `limit` and/or `cursor`; none
paginates. `GET /automation/:name/runs` even validates `cursor` at the
boundary and then returns `{ runs, hasMore: false }`, with its own
comment recording that "today's engine ignores the option entirely" —
the same shape as this door, one domain over.
- **The platform's travel is the other way.**
`api/ListNotificationsRequest:cursor` (objectstack-ai#6361) and `data.query.cursor`
(objectstack-ai#4286) were both retired before this one.

⇒ the stop condition is false in both of its conjuncts. Proceeding to
items 1 and 3 was measured, not assumed.

## Ruling item 1 — declare what the doors already execute

| door | parameter | read site | now declared on |
|---|---|---|---|
| `GET /api/v1/packages` | `type` | list branch, `manifest.type`
equality | `ListInstalledPackagesRequestSchema` |
| `GET /api/v1/packages/:id` | `version` |
`readRequestedVersion(query?.version)` |
`GetInstalledPackageRequestSchema` |
| `DELETE /api/v1/packages/:id` | `keepData` | uninstall branch |
`UninstallPackageApiRequestSchema` |
| `POST /api/v1/packages` | `overwrite` | install branch | **already
declared — see below** |

No accept set moves: the doors served all four before and serve them
identically now.

Each declaration is measured from the handler's actual read, not from
the card:

- **`type`** is an open `z.string()`, deliberately not an enum. The door
compares `manifest.type === query.type` on any non-empty value, and
`ManifestSchema.type` is no shared vocabulary — a narrower declaration
would state a rejection this wire does not perform. An unmatched value
is not an error; it selects nothing.
- **`version`** is a plain string. `latest` means "the installed row"
and is equivalent to omitting the key; comparison is exact string
equality against `manifest.version`, with no semver-range semantics, and
the id is resolved first so an unknown id keeps its existing 404
wording. All of that is in the key's docblock so the next reader does
not have to open the runtime.
- **`keepData`** is declared boolean, and the docblock records the two
spellings the wire actually honours — `keepData=true` and `keepData=1` —
and warns that anything else, `keepData=yes` included, reads as absent
and DROPS the tables. Widening the door's own comparison would be a
runtime change this declaration is not.

### ⚠️ Premise drift: `overwrite` was already discharged, by the PR that
unblocked this card

The card's body (2026-09-11) lists `?overwrite=` as read-and-undeclared.
That is no longer true. PR objectstack-ai#19130 merged 2026-09-20T11:11:16Z — the same
landing this card had been serialised behind — and it declares
`overwrite: z.boolean().optional()` on `PackageInstallRequestSchema`,
with a docblock that already names the `?overwrite=true` query spelling.
One quarter of ruling item 1 needed nothing. **No edit was made for
it**, deliberately: re-declaring it would have been churn, and the
existing declaration is better than one written from the card.

## Ruling item 3 — retire `limit` and `cursor`, `.default(50)` included

Both keys are `retiredKey()` tombstones, not deletions. The schema is
not `.strict()`, so a bare deletion makes Zod silently strip whatever a
generated client keeps sending — a clean parse and a parameter that
never takes effect, which is this card's own defect moved one layer down
(ADR-0104). Writing either key is now a `tsc` error and a parse error
carrying the prescription.

The prescription names the removed default specifically, because that is
the load-bearing half: a reader of the published schema was entitled to
believe an unparameterised list is capped at 50 rows, and it has never
been capped at all.

**The retirement kit, and the two entries it deliberately does NOT
have.** Precedent hunted and followed:
`api/ListNotificationsRequest:cursor` (objectstack-ai#6361) is the same shape one
route over — an HTTP-only request key retired with a tombstone and a D3
semantic entry. Zone 2 flagged this precedent as unverified by the seat;
it exists, and this change copies it.

- `RETIRED_KEYS_BY_MAJOR[18]` — two entries, one file each, generated
into `migrations/registry.ts` by `gen:migration-registry`.
- D3 semantic entry `packages-list-pagination-retired`, carrying
`surface` / `replacement` / `reason` / `acceptanceCriteria` to
`spec-changes.json`, the generated upgrade guide and `os migrate meta`.
- Registered at **18, not 17**, per the `ui/ListView:pageName` and
`security/ObjectPermission:allowPurge` convention: the removal ships on
the 17.x line as a minor, and the prescription lives at the major
boundary where `migrate meta` users look. The guidance string says
`17.5.0`, the shipping version, matching `view.pageName`.
- **No D2 conversion**, and the asymmetry is the point: a conversion
rewrites an authored source or a stored `sys_metadata` row, and this
shape is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and
nothing persists one. The `os migrate meta` house sentence is therefore
correctly absent from the prescription; the pin only judges
prescriptions that name the command.
- **No `acceptRetiredDefaultResidue` stage**, for the same reason one
layer along. That helper exists for a retired default the published
toolchain materialized into built artifacts. Nothing has ever parsed
this schema, so the `.default(50)` reached no artifact and there is no
residue population. The `authorable-defaults/api.json` line simply
leaves with the key — `DEFAULT_CHANGES_BY_MAJOR` excludes retirements by
name, and `check:authorable-surface` accepted it without one.
- **No liveness-ledger row** to touch: `liveness/api.json` is the `api`
METADATA type's ledger, not the spec `api/` category. Zero occurrences
of `ListInstalledPackages` in it.

**Ratchet readings, stated because their direction is route-dependent:**
`authorable-surface/api.json` gains two `[RETIRED]` rows and three new
keys; `authorable-defaults/api.json` loses exactly the `= 50` line;
`api-surface/` is unchanged, which is correct for a key-level narrowing
on a surviving def.

### `hasMore` is now true by construction, and the comment says so

`hasMore` stays the constant `false` it already was. With no `limit` and
no `cursor` to ask with, nothing can request a page, so there is never a
next one to announce. That is recorded at the response declaration — the
return site itself is in `packages/runtime/src/domains/packages.ts`,
which this dispatch is fenced off — with an explicit warning against
"fixing" the constant back into a computed value before a request-side
way to ask exists. Pinned by a test.

## Why this does not close the card

Ruling item 2 — `enabled` implemented in
`packages/runtime/src/domains/packages.ts`, one filter line in the shape
`status` already has — is assigned by the ruling to the **cli seat's
sibling PR** and is fenced off this dispatch. Measured on the merged
`origin/main` at `2277d1f`, 2026-09-20T13:30Z: `query?.enabled` occurs
**0** times in that file; control on the same file, `query?.status`
occurs **1** time. So `enabled` is still declared-and-unread after this
PR, which is one live instance of the very class this card names.

That is recorded in the schema docblock rather than glossed, and it is
why the closing line is `Part of`. PR objectstack-ai#19326, which held that file
during dispatch, turns out to be the manifest-`version` change for
objectstack-ai#19120 and has merged; it is not the `enabled` sibling.

## Verification

Readings taken in this worktree; the gate union below was run after the
final commit, at `80937f5`.

- **Gate family, derived from the real changed paths**
(`scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived after the merge): **107 derived
· 104 run green · 3 NOT MEASURED · 0 UNRUN · 0 red**, reconciled with
`--ran` carrying each exit code captured before any pipe. The three NOT
MEASURED are the gates' own `exit 3` PREREQUISITE NOT MET:
`check:dual-build-cjs-loads` (wants a whole-repo build),
`check:type-check-debt` (a re-measure, which exits 3 by design and is a
maintainer's act to act on), and `check-plugin-teardown-shape
--self-test` (wants an unshallow checkout). None is a finding.
- `pnpm --filter @objectstack/spec check:generated` — **all 15 artifacts
up to date**, at the final head.
- `pnpm --filter @objectstack/spec test` — **501 files / 14662 tests
passed**. `pnpm --filter @objectstack/spec typecheck` — clean.
- `check:test-typecheck` GRADUATED `src/api/package-api.test.ts`: its
two recorded `TS6133` unused-import errors are gone because the new
tests use both symbols, so the shrink-only ledger entry is deleted in
this PR, as that ratchet requires.
- **Reverse verification (one-off, not left in the tree).** The `limit`
tombstone was replaced on disk with its old
`z.number().int().min(1).max(100).default(50)` via
`scripts/ablation-replace.mjs`, which proved the write landed (anchor 1
→ 0, blob `de8722127dc3` → `10542945489d`) before running anything.
Direction observed: **red**, 2 failed / 68 passed — both the
prescription pin and the absence pin fire. Restore verified by blob
identity against `HEAD` and an empty `git diff HEAD`, by the tool, not
by an exit code.
- **Absence sweep, tree-scoped, with lit controls.** Authoring sites for
`limit` / `cursor` on this request shape outside the new registry
entries: **zero**; `packages.list(` calls passing either: **zero**.
Controls: `overwrite` IS found in the same spec file (10 hits) and
`packages.list(` IS found across five files by the same pattern shape.
The first-party SDK already declares `list(filters?: { status, type,
enabled })` — no `limit`, no `cursor` — so unlike objectstack-ai#6361 there is no
shipped producer to delete alongside the key.

## Acceptance notes

Out of scope, noted and deliberately not filed:

- **`gen:api-surface-declarations` output was not stable across builds
of identical sources**, and it cost this run a wrong turn worth
recording. Build objectstack-ai#1 of the unchanged `ui` sources emitted one
enum-member ordering, build objectstack-ai#2 emitted another; 184 lines of
`api-surface-declarations/ui.txt` flipped between them, and a single
control build at BASE reproduced BASE — which made one sample look like
proof that my diff caused the churn. It did not. The correct reading
needed three builds. **This finding has no surviving consumer**:
`origin/main` at `2277d1f` reverted the whole declaration-text snapshot
(objectstack-ai#19024) and deleted `api-surface-declarations/` along with its gate,
which is also the merge conflict this branch resolved by accepting the
deletion. Successor: none. Recorded here rather than filed because the
artefact and the gate that read it no longer exist.
- **Three sibling list doors carry the same declared-not-honoured
pagination shape** — `ListExportJobsRequestSchema` (`limit` with
`.default(20)`, `cursor`), `ListAiConversationsRequestSchema` (`limit`,
`cursor`) and `ListRunsRequestSchema` (`limit`, `cursor`, the last
validated at the boundary and then ignored by the engine, with `hasMore:
false` hard-coded). This is a reproducible contract divergence of
exactly this card's class, on doors this card does not name, and the
handback report carries it with dedupe words for the seat to file. ⛔ Not
filed from here and ⛔ not widened onto this PR.
- The `/packages` dispatcher domain declares no closed query-parameter
set, so an unrecognised name is still dropped rather than refused. That
is route 3, which the ruling considered and refused; noted so a later
reader does not read this PR as having taken it. Successor: whoever
converts the dispatcher domains per the incremental ingress lane.

Landing waits for the seat: this PR is a contract-review carrier and the
seat handles both the carrier and the at-tier review. Nothing here flips
ready, enqueues, arms auto-merge, requests review, or writes a label or
assignee.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…-only), plus the merged-result probe (objectstack-ai#19259)

Fixes objectstack-ai#18224

Two gates were registered in the root manifest and invoked by **zero**
workflows:
`scripts/check-issue-citations.mjs` (delivered by objectstack-ai#18223 / card objectstack-ai#17512)
and
`scripts/check-merged-result.mjs` (delivered by objectstack-ai#18338 / card objectstack-ai#16287).
Both cards' declared
file surfaces excluded `.github/workflows/**`, so both devs correctly
stopped and filed the
wiring rather than widening. This PR is that wiring.

## The three entry points, and their postures

| entry | lane | posture |
| :--- | :--- | :--- |
| the DIFF-scoped citation verdict | `lint.yml` · `Lint & Repo Gates` |
**blocking** |
| `check:merged-result` self-test | `lint.yml` · `Lint & Repo Gates` |
**blocking** |
| `check-issue-citations --census` | `half-state-patrol.yml` (scheduled)
| **report-only, never blocking** |

The census posture is a ruling, not a preference, and the card carries
the measurement that
forces it: **objectstack-ai#16783, objectstack-ai#16786 and objectstack-ai#16787 were measured RESOLVING on
2026-09-10 and 404 on
2026-09-14, with no change to this tree.** A tree-wide blocking verdict
would therefore red a
motionless repository because a third party deleted an issue. The
diff-scoped half is the part
an author owns, it is small, and it is the only thing that stops 2,785
unresolvable sites
becoming 3,000.

## The manifest alias is NOT the verdict

`package.json` maps `check:issue-citations` to `node
scripts/check-issue-citations.mjs --self-test`
and nothing else -- the shape every credential-needing gate in this
manifest uses
(`check:pm-half-states`, `check:pm-closed-card-sweep`), because a live
mode needs a board and a
credential. Wiring that alias alone would have run the self-test twice
and scanned nothing. So
the lint step holds **two** commands, self-test first:

```
pnpm check:issue-citations && node scripts/check-issue-citations.mjs
```

The second spelling is lint.yml's documented gate-invocation idiom
(`dispatch-gates.mjs`'s own
header names it), and the first is what `check-self-test-wired` requires
of every script CI runs.

`check:merged-result` needed no such split: the manifest key already IS
`node scripts/check-merged-result.mjs --self-test`, which is the whole
gate.

## `package.json` is deliberately untouched

No new manifest key was added. Both keys already exist and both are now
named by `lint.yml`, so
the wiring needs no manifest edit, and leaving the file alone keeps this
PR textually disjoint
from PR objectstack-ai#18414, which adds a key two lines from where a new one would
have gone.

## The non-step changes: `issues: read` AND `pull-requests: read`

> ⚠️ Heading corrected by the dispatching PM at 2026-09-20T07:25Z: this
section was written when the block
> gained ONE scope. It now adds TWO — `pull-requests: read` followed
from a measurement taken after
> the body was written (patrol run 35495222460 censused **3628**
unresolvable sites on a token without it,
> run 35496169133 on the fixed head censused **2167**, the exact
full-scope reading; the 1460 difference is
> exactly the `resolves-as-pull-request` tally, because `GET /issues`
omits pull requests without that scope).
> ⛔ Nothing else in this body was altered.

The `Lint & Repo Gates` job's `permissions:` block gains `issues: read`.
An explicit
`permissions:` block sets every unnamed scope to `none`; this board is
public today, but a
**blocking** gate whose transport depends on repository visibility is a
gate that goes red on a
settings change no file in this repo can assert. Read-only, one scope
wide: the gate never
writes an issue, a comment, a label or an assignee.

## Acceptance 2 -- both directions measured, on the CI side

PR objectstack-ai#18223 measured the red/green pair on the **gate** side. This card
asks for the same pair on
the **CI** side. Two instruments, both here.

**A. The commits on this branch ARE the CI-side ablation.** `test(ci):
ABLATION LEG 1 of 2`
adds one citation naming a number beyond this board's allocation
frontier, in a declared surface
(`packages/**/src/**/*.ts`, comment-prose projection). `ABLATION LEG 2
of 2` removes it and
restores the file to the byte. The run on the first head is the red
reading; the run on the final
head is the green one. Both run identifiers are recorded in the dev
report on the card.

**B. The exact command the new step holds, ablated locally with disk
evidence.** Three legs, run
from a committed state, each restored with `git checkout HEAD -- path`
and proven by hash:

```
target                packages/types/src/index.ts
HEAD blob             0235d4e

leg 1  unresolvable   marker 0 -> 1   blob 0235d4e -> 2ac9ba3   exit 2   RED
       finding: [never-issued] packages/types/src/index.ts:8  #999999
                beyond the allocation frontier (19258) -- this number was never minted

leg 2  restored       marker 1 -> 0   blob back to 0235d4e       exit 0   GREEN
       `git diff HEAD` empty after the restore

leg 3  resolvable     cites objectstack-ai#17512 instead                          exit 0   GREEN
       board: probed (1 citations), frontier objectstack-ai#19258, 2 numbers resolve
```

Leg 3 is the control on leg 2: a green produced by a live board read,
not by a run that never
asked the board anything.

## Acceptance 3 -- where the census reports, how often, who pays

Written into the step itself, and repeated here:

- **Where.** The patrol run's **step summary** and job log, plus one
`::warning::` carrying the
site count. Deliberately **not** the anchor issue: that body is owned
end to end by
`check-half-states.mjs`'s generator, and a second writer is how half a
generated body goes stale.
- **How often.** This workflow's schedule -- four times a day, six hours
apart (`37 1,7,13,19`) --
plus any `workflow_dispatch`, plus the `pull_request` runs the paths
filter admits. A row for
`scripts/check-issue-citations.mjs` was added to that filter for the
reason the file already
gives for its two siblings: a step whose script can change without the
trigger firing is a step
  whose PR-time proof is a coincidence.
- **Who pays the 159 requests/run.** This repository's own
`secrets.GITHUB_TOKEN` core quota --
the same 5,000/hour the job already draws the live sweep from. Four runs
a day is roughly 636
requests/day, under half a percent of a single hour's allowance. No PAT
and no cross-repo
  credential, per this file's own standing rule.

The step is gated on `github.repository ==
'objectstack-ai/objectstack'`, exactly as the
closed-card sweep above it is, because `half-state-patrol.yml` is copied
verbatim into sibling
repos that do not carry this script -- a copy must skip the step, not
fail on a missing file. It
is placed **after** the anchor write, unlike the closed-card sweep: the
anchor is this patrol's
product, the job has a 15-minute timeout, and a report-only reading must
never be able to starve
it. It always exits 0.

## Acceptance 4 -- the 422 wall

This diff touches `.github/workflows/**`, which is outside the PM seat's
arming channel: the
seat's `auto_merge` answers **HTTP 422** for this PR. **It merges by a
human.** That is the same
wall as PR objectstack-ai#18096 and objectstack-ai#18341, it is not a tool fault, and it must not be
retried. No seat should
undraft this PR or arm auto-merge on it.

Note that `.github/workflows/**` is *not* on the `GOVERNED_SURFACES`
register in
`scripts/pm/check-governed-merges.mjs`, so the governed-merge machinery
is not what holds this
one -- the 422 is.

## Placement, and the three in-flight PRs on these files

Read before editing: objectstack-ai#18414 (`lint.yml` + `package.json`, green,
awaiting a human merge),
objectstack-ai#19024 (`lint.yml`), objectstack-ai#19225 (`half-state-patrol.yml`, draft and frozen).
Only additions here; no
existing step was moved, renumbered or reformatted.

- In `lint.yml` the two steps go **above** the `objectstack-ai#15149`
step-name-quoting step, which keeps that
step's own documented placement ("immediately above" the
duration-unit-keys step) true and keeps
the duration-unit-keys step last among the gates. objectstack-ai#18414 inserts at the
control-byte guard
(line ~411) and objectstack-ai#19024 edits the typecheck lanes (lines ~5173 and
~6104), so all three hunks are
  disjoint.
- In `half-state-patrol.yml` the census step goes between the summary
publish and the final
fail step. objectstack-ai#19225 rewrites that file wholesale into a composite action
and is frozen behind a
`pm:blocked` card; a textual conflict there is expected and was accepted
at dispatch.

## Measurement this PR does not relay

The card's prose carries three disagreeing counts for the manifest
census. Re-measured on this
branch's base `0f42d36ff`, with a firing control and a dark control:

```
root manifest check:* keys                 165
not named by any workflow (name or path)     2   -> check:merged-result, check:issue-citations
...and not named by any other script         2
firing control 'check:nul-bytes' in a wf   true
dark control   'check:zznotreal' in a wf   false
```

Both unreached keys are the two this PR wires, so the reading after this
lands is 0.

## Acceptance notes

- `check-self-test-wired` admits a script when a workflow names it
directly or through a root
manifest alias, repo-wide rather than per workflow, so the patrol's
census step needs no second
  self-test invocation: the lint step above already runs it.
- No changeset: nothing any package publishes moves. Workflow files ship
in no package's `files[]`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: os-elon-musk <elon-musk@objectstack.ai>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…are supported from protocol 16 onward (objectstack-ai#19302)

Fixes objectstack-ai#19056

Clause-②: yes (narrowing)

Maintainer ruling, 2026-09-18, verbatim and untranslated:

> 升级只需要支持从 16.0版本开始。

`MIGRATION_SUPPORT_FLOOR` (`packages/spec/src/migrations/registry.ts`)
moves **10 to 16**, and `step11`–`step16` retire with it. The direction
was not re-argued. 「16.0」 reads as protocol **major 16** — the same unit
as the constant, since `PROTOCOL_VERSION` is `17.0.0` while the package
version is `17.4.0`.

## What landed

| item | disposition |
| --- | --- |
| `MIGRATION_SUPPORT_FLOOR` | `10` to `16` |
| `step11`–`step16` and their `MIGRATIONS_BY_MAJOR` registrations |
deleted (328 lines) |
| the 10 `entries/semantic/` files prefixed `11. 12. 13. 15. 16.` |
deleted, then `gen:migration-registry` re-emitted the marked regions —
never hand-edited between the markers |
| replay fixtures below 16 | re-pointed at the oldest hop the chain
still guarantees, floor to floor+1 |
| `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` | **kept whole** —
see below |

Regenerated and committed: `spec-changes.json` and
`docs/protocol-upgrade-guide.md`. (`api-surface-declarations/root.txt`
was regenerated at the FIRST head; `main` then deleted that whole
directory in `2277d1fcd1` (objectstack-ai#19024), so this head takes main's deletion
and the roster is 15 artifacts.) `pnpm --filter @objectstack/spec
check:generated` proved exactly those three stale and `--fix`
regenerated only those three; the other 13 artifacts,
`check:authorable-surface` included, were green throughout.

## The starred undecided item: both retirement tables are KEPT, and here
is the proof

The card asked whether `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR`
serve only the migration chain, or are also read as an independent
retirement fact. **They are read as an independent fact, and the reading
does not depend on the floor.** Three measurements:

1. **Neither table has a row under 11–16 at all.** Both literals carry
exactly two keys, `17` and `18`; the `entries/retired-keys/` and
`entries/retired-defs/` directories hold only `17.*` and `18.*` files
(195 and 181 of them). The card's change-table row "rows 11–16"
describes rows that do not exist, so there was nothing to delete even
before the question of whether it would be safe.

2. **Structural.** `chain.ts` — the floor's only enforcement point —
imports `MIGRATIONS_BY_MAJOR`, `MIGRATION_MAJORS` and
`MIGRATION_SUPPORT_FLOOR`, and neither table. The tables' only non-test
importer is `packages/spec/scripts/build-schemas.ts:116`
(`check:authorable-surface`), which folds `Object.entries(...)` across
**every** major into one set and never mentions
`MIGRATION_SUPPORT_FLOOR` at all. The major is kept only to date a
tombstone's aging clock.

3. **Ablation** (mutation and restore both proved on disk by
`scripts/ablation-replace.mjs`). A row naming a **live** key was planted
under major **11** — a major whose migration step this PR deletes — and
`check:authorable-surface` still read it and judged it:

   ```
   ❌ 1 RETIRED_KEYS_BY_MAJOR entr(ies) name a key that is still LIVE:
        - data/Object:access  (registered at major 11)
   ```

Control: the same gate is green on the unmutated tree. Restore leg:
`blob == HEAD (d4cb483)` and `git diff HEAD` empty.

So a row below the floor is still the live proof that its retirement was
declared, and dropping one errors nowhere at the moment it is dropped —
exactly the silent loss objectstack-ai#6957 measured. Both facts are now pinned in
`packages/spec/src/migrations/retired-tables-not-floor-scoped.test.ts`
so the next floor move reads them before reaching for the delete key.

The **D2 conversion registry** is untouched for the same class of
reason: every rehydration seam replays the full conversion chain over
stored `sys_metadata` rows, retired entries included, so the
protocol-11/13/14/15 conversions keep converting rows at rest long after
the source-side chain stops reaching them. What the floor removed is the
D3 step that carried them — which is why they leave the chain-replay
gate and nothing else. The test says so where a future reader will look.

## This is not a slimming change

The card corrects its own filer and this PR keeps that correction.
Measured on `src/migrations/registry.ts` at `e6a03e649` (17,718 lines):

| block | lines | share |
| --- | ---: | ---: |
| `step11`–`step16` — what leaves | 328 | 1.9% |
| `step17` | 4,699 | 26.5% |
| `step18` | 7,565 | 42.7% |
| registration map plus the two retirement tables | 5,077 | 28.7% |
| file header | 49 | 0.3% |

The value is a **narrowed support promise**: six permanently-replayable
chains no longer have to be maintained, and the CI replay shrinks to the
range the project actually promises — 10 of the 99 conversion fixtures
leave the chain-replay gate because the chain no longer reaches the
major that graduated them.

## Cost, and where it is written down

`MIGRATION_SUPPORT_FLOOR` is a published export
(`migrations/index.ts:23`). After the raise `applyMetaMigrations(doc,
N)` throws `MigrationFloorError` for N in 10..15, so a consumer stopped
at protocol 10–15 loses the one-command upgrade path; the remedy is to
reach protocol 16 by another path first, which is what the refusal
message already says. The literal TYPE of the constant also narrows from
`10` to `16`.

The changeset is **`minor`, not `major`** — this is the repo's own
convention, not a judgement call about severity:
`scripts/check-changeset-no-major.mjs` refuses a `major` bump for the
duration of the launch window (every publishable package is in one
Changesets `fixed` group, so one `major` promotes the whole stack), and
it names the two mandatory carriers that replace the bump level
meanwhile — the `**BREAKING**` banner and the ADR-0087 disposition. Both
are in the changeset. Measured: `check-adr-0087-registration.mjs` reads
it as `[BREAKING+bang+clause-②-narrowing] not-required
(no-migration-prescription)` and exits 0.

## One deliberate out-of-surface fix

`packages/cli/src/commands/migrate/meta.ts` advertised four examples —
`--from 10`, `--from 10 --step`, `--from 11 --to 12`, `--from 10 --out
…` — and this change makes **every one of them refuse**. That is a
published defect this PR creates, in help text that ships in
`@objectstack/cli`, so it is fixed here rather than filed. The examples
are now derived from `MIGRATION_SUPPORT_FLOOR`, which closes the class
instead of the instance: the next floor move cannot leave them
advertising commands that throw.
`packages/spec/scripts/build-upgrade-guide.ts` carried the same
hard-coded `--from 10` and is derived the same way.

## Tests

- `pnpm --filter @objectstack/spec test` — **500 files, 14,639 tests,
all pass** (1 file / 1 test skipped, pre-existing).
- `pnpm --filter @objectstack/spec typecheck` — **Done**, green (tsc,
scripts tsconfig, and the test-layer debt gate).
- `pnpm --filter @objectstack/spec check:generated` — **15** artifacts,
all current. (15, not 16: `check:api-surface-declarations` left the
roster when `main` deleted the directory in `2277d1fcd1`.)
- Gates green: `check:migration-registry`, `check:authorable-surface`,
`check:api-surface`, `check:api-surface-declarations`,
`check:spec-changes`, `check:upgrade-guide`, `check:docs`,
`check:published-files`, `check:cross-package-test-inputs`,
`check:test-source-alias`, `check:type-check-coverage`,
`check:nul-bytes`, `check:merge-driver`, `check:spec-parsed-alias`,
`check-adr-0087-registration`, `check-changeset-no-major`,
`check-empty-changeset`, `check-closing-keyword-parity`,
`check-undeclared-dep-imports`, `check-ci-filter-parity`,
`check-spec-docblock-symbol-anchors`, `check-comment-mask-adoption`,
`check-keyed-text-bounds`, `docs-audit/check-affected-docs`.
- **NOT MEASURED**, with reasons: `packages/cli` typecheck and
`check:type-check-debt` both need the whole workspace `dist/` closure
built first. The debt gate says so itself and refuses with its own exit
code 3 — `PREREQUISITE NOT MET`, explicitly "NOT a pass and NOT a
finding". The closure build did not get a turn on this container's
shared verify lock (`exit 99`, queue timeout after 9 minutes). The one
error the cli typecheck reports inside the edited file is `TS2307 Cannot
find module '@objectstack/metadata-protocol'` at `meta.ts:709` — an
unbuilt-closure symptom on a line this PR does not touch, alongside 230
more of the same code across the package. CI builds the closure before
this step and is the authority here.

Test changes are re-pointings, not deletions: the replay fixtures, the
composability gate and the manifest-composition cases all now read
`MIGRATION_SUPPORT_FLOOR` rather than the literal `10`, so the next
floor move re-points them instead of inviting another delete. Four
assertions were added where the old ones could not see the move: a step
at or below the floor is dead code and must not survive; `--from floor`
must be a usable command (the floor+1 hop has to exist); every major the
raise dropped is refused **by name**, with `fromMajor`, `floor` and the
message; and the chain-replay gate states why a below-floor conversion
leaves it, with an anti-vacuity case so the filter cannot empty the gate
silently.

## Acceptance notes

Out of scope for this PR, recorded rather than fixed:

- **`skills/objectstack-upgrade/SKILL.md:103`** advertises `os migrate
meta --from 10`, which refuses after this change. `skills/**` is a Tier
H governed surface and one governed path forks the whole PR to Tier H,
so fixing it here would hold this diff for the maintainer's hand.
Deliberately left; a one-line docs-only change. This is a real conflict
between two binding rules (fix what this change falsifies, versus do not
fork a code PR to Tier H) and it is named here rather than quietly
resolved.
- **`packages/metadata-core/src/protocol-handshake.ts:252`** builds
`objectstack migrate meta --from TARGET` from the incompatible package's
own declared target major, with no clamp to the floor, so a package
targeting a major below the floor is handed a command that throws.
Pre-existing — its own test already pins a `--from 6` answer — and
widened from "below 10" to "below 16" by this change. Reproducible
defect; worth a card.
- **The card's blast-radius table over-counts `step18`.** It lists
12,041 lines; measured, the `step18` block is **7,565**. The 12,041
figure runs from `step18` to end of file and therefore absorbs
`MIGRATIONS_BY_MAJOR` plus both retirement tables — about 5,077 lines,
i.e. precisely the tables the same card says must not be deleted. The
card's conclusion is unaffected and stands. Noted, not filed.
- `docs/adr/0087-...md:181,426` and `content/docs/releases/v15.mdx:520`
narrate `migrate meta --from 10` as history. Both are correct as history
and both are on surfaces a code PR must not edit (governed;
release-owned). Noted, not filed. Carrier: none — no open PR touches
either file.

---

> ⭐ **Seat correction, 2026-09-20T13:53Z — two claims above were true at
the FIRST head and are false at this one.** Both are edited in place:
the regenerated-file list and the `check:generated` artifact count. The
cause is outside this PR — `main` deleted
`packages/spec/api-surface-declarations/` in `2277d1fcd1` (objectstack-ai#19024,
ruling C) at 12:52Z, which took that gate off the roster.
>
> The implementer flagged both rather than PATCHing this body itself:
its contract writes a PR body once, on the call that opens the PR, and ⛔
never edits it. That is the correct division, and this edit is the
seat's half of it.
>
> ⚠️ **One requirement of the contract review (`5749969134`, ①-7) was
MEASURED FALSE and deliberately not carried out.** The review asked that
`api-surface-signatures.json` be regenerated "in its restored hash form
… because your `10`→`16` literal moves a declaration signature". It does
not: that artifact records a hash of each `defineX` **factory**
signature (27 of them), and `MIGRATION_SUPPORT_FLOOR` is a plain
`const`, so it is not in that artifact at all. The snapshot that WOULD
have carried it is `api-surface-declarations/root.txt` — the very file
`2277d1fcd1` removed. The implementer ran the generator anyway rather
than argue from reading: `gen:api-surface` exit 0, `git status
--porcelain` **empty** — identical bytes. ⇒ the prediction was about an
artifact this repository no longer has.
>
> ⚠️ The review also said **rebase**; the implementer **merged** instead
and said so, citing `AGENTS.md` (⛔ no rebase or force-update of a
pushed, reviewed branch) and `scripts/pm/os-regen-merge.sh`, whose own
header prescribes `git merge origin/main` and ⛔ `never rebase /
force-push` — because the os-regen merge driver can drop one side at
exit 0 and only that script's ORDER exposes it. The seat accepts both
departures: each is measured, named, and backed by in-repo authority.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…es option, and a pin reads the ruleset (objectstack-ai#19448)

Fixes objectstack-ai#19344

Clause-②: no

Item 6 of the maintainer's ratified directive — verbatim 「1 2 3 4 5 6 8」
(record `5750077963`, relayed onto this card as comment `5750078192`) —
narrowed by the director's pointer `5749924838`: route **(a)** was taken
in person, PR objectstack-ai#19024 merged by bypass (`merged_by` os-zhuang, squash
`2277d1f`). What remained is exactly what this PR does — the guard's
remedy sentences name a path that EXISTS on ruleset `main`, and a pin
fails when they name one it does not offer.

## The defect

Ruleset `main` (id 12119582, enforcement `active`, target the default
branch) carries a `merge_queue` rule and lists `Governed Surface Queue
Guard` among its seven required contexts. So the only Merge button an
ordinary account is offered is "Merge when ready" = enqueue, and the
size limb then refuses the queued group. The remedy prescribed 人工直合 —
"the maintainer's own click" — without naming WHICH click, and the only
click that is not an enqueue is the Merge button's **bypass-rules**
option, offered only while the ruleset configures a bypass actor. With
none configured, the remedy named a terminal nobody could reach: PR
objectstack-ai#19024 was enqueued three times and refused three times.

## Before / after — the four sentences

Each keeps 人工直合 as the NAME of the act (the 2026-09-18 ruling stands
untouched); what changes is the description of how the act is reached.

**1. The header sentence (`:410`, the size limb's own contract)**

Before:

```
 * maintainer's own click (人工直合). An authorized APPROVED review lifts a
```

After: the same opener, then — `main` mandates the queue and requires
this check, so the only Merge that is not an enqueue is the Merge
button's BYPASS-RULES option, offered only while the ruleset configures
a bypass actor; while none was, the remedy named a terminal nobody could
reach and PR objectstack-ai#19024 was enqueued and refused three times; that it IS
offered is a ruleset fact the pin reads.

**2. The governed limb's early warning (`renderGuardVerdict`, Tier H
block)**

Before:

```
Unapproved, the maintainer's own direct merge (人工直合) is
the only landing this pull request has.
```

After:

```
Unapproved, the maintainer's own direct merge (人工直合) is the
only landing this pull request has, and it IS the Merge button's bypass-rules option —
offered only while ruleset `main` configures a bypass actor (objectstack-ai#19344).
```

**3. The governed limb's refusal (`renderGuardVerdict`, remedy item 2)**
— same replacement, plus "the audit log records it".

**4. The SIZE limb's refusal (`renderSizeVerdict`, remedy item 2)** —
rendered on the objectstack-ai#19024 shape:

```
        2. Then a HUMAN MERGE — the same terminal a governed diff has: ACCEPT on the card,
           `needs-user-decision` on the PR, a final 维护者速读, review requested from GOVERNED_APPROVERS
           (os-zhuang, hotlong); the maintainer's own click lands it (人工直合) — and that
           click is the Merge button's bypass-rules option, offered only while ruleset `main` configures a
           bypass actor — ⛔ NOT a second Merge button: `main` mandates the queue and requires this check, so
           with none configured every re-enqueue comes back here (objectstack-ai#19344). The audit log records the bypass
           and `check-governed-merges` lists such a landing on size.
```

## The ruleset reading this seat measured

`GET /repos/objectstack-ai/objectstack/rulesets/12119582`, with this
seat's token, on the day this PR was written: **HTTP 200**, and the
response carries **no `bypass_actors` key at all** — not `null`, absent.
The keys it does return are `id name target source_type source
enforcement conditions rules node_id created_at updated_at
current_user_can_bypass _links`, and `current_user_can_bypass` reads
`"never"`.

That is a different fact and is recorded beside it: it says THIS token
is not itself a bypass actor, which is true of every agent seat and says
nothing about whether the ruleset configures one for the maintainer. The
director's earlier pointer read the field as `null`; this seat's read
gets no key. Both are "cannot conclude", and the pin treats them
differently only in what it prints.

`check-required-contexts.mjs`'s measured header is the authority on why:
the ruleset endpoints answer 200 for `metadata=read`, but the bypass
roster is an `administration` field, and `administration` is not one of
the 17 permissions a workflow may grant its `GITHUB_TOKEN`. So no token
this repository's CI can hold will ever read `bypass_actors`.

## The pin, and its three verdicts

One new battery in `--self-test`, five cases, driving two new pure
exports (`bypassActorReading`, `remedyPathVerdict`) over a frozen copy
of the measured response:

| `bypass_actors` as read | reading | pin |
|:--|:--|:--|
| key absent (this seat's and every Actions token's answer) |
`unreadable` | **PASSES**, printing `bypass_actors: unreadable with this
token (the key is absent); current_user_can_bypass: "never"` |
| present and `[]` or `null` | `not-offered` | **REDS** — the remedy
names a path the ruleset does not offer |
| present with at least one actor | `offered` | PASSES |

Two ways to red, not one: the ruleset is READ to offer none, **or** the
remedy stops naming a path at all — which is the defect this card filed,
and a pin that only checked the first would sit green through it. The
self-test prints the reading on every run, so "unreadable" is never a
silent pass. ⛔ It never asserts a path from a field it did not read, and
⛔ it never reds CI on a permission difference.

## Recorded, not live — the four axes

The card allowed either a live read or a recorded fixture. **Recorded,
and the live read deliberately not added.**

- **实际业务需求** — measured, not supposed: `bypass_actors` is unreadable to
this seat's token AND to any Actions token (above). A live read wired
into this self-test would therefore answer `unreadable` on every CI run
in existence — it would assert nothing, on every run, while adding a
network call. The real need is that the remedy sentence names a
reachable path; the only party who can verify reachability is the
maintainer, and their verification already happened (the objectstack-ai#19024 bypass
merge). A recorded reading is what that evidence looks like in this
file.
- **项目长远合理性** — this self-test is the **first step** of the required
`Governed Surface Queue Guard` job, run under `bash -e` as the
precondition for trusting the guard, and its own usage line declares it
`offline, no network, no git`. Making a merge precondition depend on
api.github.com reachability and on a token's permission tier is the
permanently-red-gate shape this repo has already retired. The precedent
is in-repo and exact: `check-required-contexts.mjs` keeps a frozen
`RULESET_SNAPSHOT` in its self-test and leaves the live diff to a
report-only mode that never runs in CI, for the same structural reason.
- **防 AI 写代码犯错** — a live read is the lenient-consumer shape here: it
passes for every token that cannot see the field, so the assertion would
be phantom and the next author would read green as "the path is
reachable". The recorded form makes the claim declared and falsifiable —
change the remedy, and the pin demands a reading that offers the path;
the reading is printed rather than swallowed.
- **创业阶段不扩散需求** — one battery, two pure exports, one frozen object, no
new entry point, no new script, no new workflow, no widened token scope.
Net **+60 lines**, exactly the budget.

The fixture's cost is drift, and it is bounded on purpose: it carries
its provenance in the comment above it (endpoint, id, read date, token
class), its reading is printed on every self-test run rather than
asserted silently, and the field it records is one no CI token can
re-read anyway — so a live read would not have detected drift either.

## Evidence

- `node scripts/pm/check-governed-queue-guard.mjs --self-test` :: exit 0
— **301 cases** (296 before; +5 is exactly the new battery), and the run
prints `ℹ objectstack-ai#19344 ruleset reading — bypass_actors: unreadable with this
token (the key is absent); current_user_can_bypass: "never"`.
- **Ablation** (`scripts/ablation-replace.mjs`, wrap mode, on the
committed state): anchor `click is the Merge button's bypass-rules
option` replaced in `renderSizeVerdict` — on-disk proof `anchor 1 -> 0,
blob 5b75964 -> e6ee4e00f482` — self-test went **red, 3 of 301**,
naming `all-four-remedies-NAME-the-bypass-rules-option…`,
`a-field-this-token-cannot-see-is-UNREADABLE-and-PASSES…` and
`one-configured-bypass-actor-makes-the-named-path-REACHABLE…`. Restore
proven byte-identical: `blob == HEAD (5b75964)` and `git diff HEAD`
empty. The direction is the expected one (red), and it reds via BOTH
limbs of the contract, which is what "two ways to red" means.
- **Derived gate union** (`node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`, no paths, 31 families): 30 run with
exit captured before any pipe, **all exit 0**; `pnpm
check:pm-dispatch-gates` was still running when this PR was opened and
is reported in the card comment. `--ran` reconciliation: 31 derived, 30
accounted with real exit codes, 0 NOT-MEASURED among them.
- `node scripts/pm/check-governed-merges.mjs --test
scripts/pm/check-governed-queue-guard.mjs` :: exit 0 — **NOT governed**;
ordinary queue landing applies. `--pr 19024` :: exit 3 — the
size-decided landing is listed, which is the recognition the remedy text
now points at.
- PM mechanism assumptions: (1) confirmed — the three sentences read as
described on `origin/main`; (2) PR objectstack-ai#19379's region `:268–:294` is
untouched by this diff; (3) confirmed — the self-test is wired at
`.github/workflows/governed-surface-guard.yml`, extended in place, no
second entry point; (4) confirmed above.

## Acceptance notes

- The ruleset response carries `current_user_can_bypass`, which an
ordinary token CAN read and which directly answers "is the bypass option
offered to the account asking". Nothing in this repo probes it live; it
is recorded in this fixture only. Observation, not filed — it is a
capability nobody has pulled on, and the four-axis call above is not to
add a live probe to a required precondition.
- `check-required-contexts.mjs`'s `RULESET_SNAPSHOT` records the
2026-08-18 reading with **six** required contexts; the live ruleset now
carries **seven** (`Governed Surface Queue Guard` joined since). That
file's assertions are deliberately written on the SHAPE, not on
membership, and its header says so, so nothing is wrong — but the
snapshot is a frozen historical reading and reads at a glance like a
current one. Observation, not filed.
- ⛔ Not touched, per the card's own "Not this card": the 5000-line
threshold (it lives in `check-governed-merges.mjs`, imported here), the
required-context set, the 「THIRD leg」 header at `:268–:294` (PR objectstack-ai#19379's
region), and any workflow.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…arries UNIQUE_VIOLATION, and the packaged-set lock answers first (objectstack-ai#19437)

Fixes objectstack-ai#19307

Clause-②: yes

The data door's duplicate-name refusal on `sys_permission_set` had two
halves, both reproduced on today's head (`c33707933`) before any edit
and re-measured after, on `examples/app-showcase` with a seeded admin
over a cookie session.

## The two halves

**1. No machine-readable `code`.** The insert leg threw a bare `Error`
carrying `.status = 409` and no `.code`. The flat `{ error, code }`
responder invents nothing for a producer that declared nothing, so the
caller got prose — against ADR-0112's 2026-08-17 amendment (objectstack-ai#9232),
under which the flat door carries the closed member too.

**2. ⭐ It ran BEFORE `assertPermissionSetNotPackageDeclared`.** A
package-declared set has a projected row, so its name is duplicate AND
locked at once. The admin who opens the Clone dialog on a packaged set
and types the base set's own name — the single most likely thing to type
— got `already exists`, which names no remedy, and never reached
`NOT_OVERRIDABLE`, which names the clone path.

## Live readings — five legs, same script, before and after

Script: `POST /api/v1/data/sys_permission_set` as the seeded admin; full
text in the report comment on objectstack-ai#19307.

| leg | BEFORE (`c33707933`) | AFTER (this branch, built) |
|:--|:--|:--|
| 1. duplicate name = **package-declared** `showcase_manager` | `409` ·
`{"error":"[Security] permission set 'showcase_manager' already
exists","object":"sys_permission_set"}` — **no `code` key** | `403` ·
`"code":"NOT_OVERRIDABLE"`, message: *"…Choose a different name for your
set, or clone 'showcase_manager' (the "Clone" action…)"* |
| 2. create a NON-packaged set | `201` | `201` (unchanged) |
| 3. ⚖️ negative control — duplicate of that NON-packaged set | `409` ·
no `code` | `409` · `"code":"UNIQUE_VIOLATION"`, message byte-identical
|
| 4. ⚖️ contrast control — unauthenticated `PATCH`, same resource |
`401` · `"code":"UNAUTHENTICATED"` | `401` · `"code":"UNAUTHENTICATED"`
(unchanged) |
| 5. ⚖️ contrast control — `PATCH` the packaged set (non-duplicate
route) | `403` · `"code":"NOT_OVERRIDABLE"` | unchanged |

Legs 4 and 5 are what make legs 1 and 3 readings rather than constants:
the flat door already varied its `code` by path, so the absence was this
producer's and never the door's.

## Why `UNIQUE_VIOLATION`, reused and not minted

`sys_permission_set` declares `{ fields: ['name'], unique:
'organization' }`, and the reading recorded on that index itself (objectstack-ai#8554)
is `org_yi 409 UNIQUE_VIOLATION`. So the platform **already** answers
this exact collision with this exact envelope whenever the index catches
it instead of the projection's pre-check. A second spelling here would
make one condition answer two envelopes depending only on which layer
got there first — the drift `@objectstack/rest` and
`@objectstack/driver-memory` deliberately registered the SAME code to
avoid. `RESOURCE_CONFLICT` (the standard member 409 derives from) is
what the door supplies for a producer that named no condition; using it
would be that second spelling.

## The `packages/spec` touch is one provenance row

`check:error-code-provenance` recognises `objlit`, `assign` and `*_CODE`
`constdef` stamp sites and states in its own bounds line that it is
**blind to class fields**. Written as a class-field literal, this
package would have become an unlisted EMITTER of a registered code with
every gate in the repo green — the invisibility the ledger header names
("no admission rule checks WHO emits"), found by hand three times
already (objectstack-ai#7504 / objectstack-ai#13254 / objectstack-ai#13353). So the code is stamped through an
exported `PERMISSION_SET_NAME_CONFLICT_CODE`, which puts the emitter
inside the gate's field of view, and the ledger gains the matching row
under `@objectstack/plugin-security`.

Measured red then green, in that order:

- class-field spelling, no ledger row: gate **exit 0** — it never saw
the stamp;
- `*_CODE` constant, no ledger row: gate **exit 1** —
*"@objectstack/plugin-security stamps 'UNIQUE_VIOLATION' (constdef) at
packages/plugins/plugin-security/src/errors.ts:372 — not listed under
its own owner key"*;
- `*_CODE` constant + the row: gate **exit 0**, 339 stamp sites, 322
listed.

**It is provenance, not identity, and that is measured rather than
asserted.** The deduped ledger union is **282 distinct codes before and
282 after, added [] / removed []** — `UNIQUE_VIOLATION` was already a
member under two other packages. `pnpm --filter @objectstack/spec
check:generated` reports all 15 generated artifacts up to date, which
supports ONE claim only — no regeneration is MISSING. ⛔ It does NOT show
the published TYPE is unchanged, and here it is not: `ERROR_CODE_LEDGER`
is exported `as const satisfies`, so this row moves `typeof
ERROR_CODE_LEDGER['@objectstack/plugin-security']` from a 3-tuple to a
4-tuple. `check:api-surface` stayed GREEN THROUGH that change, because
`api-surface/api.json` records the symbol name (`ERROR_CODE_LEDGER
(const)`) and no signature, `api-surface-signatures.json` covers 27
`define*` helpers and not this const, and `api-surface-declarations/`
was withdrawn by objectstack-ai#19024 and does not exist on this base. That is why
this PR declares `Clause-②: yes` and grades `@objectstack/spec` `minor`.

⚠️ `check-widening-tells` still fires **T4** on that line with
`Clause-②: no` (exit 4), because the matcher cannot see the dedupe — it
reads any new registry entry as an acceptance-set growth. The
measurement above is the evidence it is false here. Reported for the
owning seat rather than repaired in this PR; the declaration line is
copied verbatim from the claim and is not mine to move.

## One corner moved with the order, declared not incidental

An ordinary duplicate attempted while **no artifact source can answer**
now takes the lock's fail-closed `unknown` refusal — `403`
`NOT_OVERRIDABLE` (`PackagedPermissionSetProvenanceUnknownError`, "retry
once the metadata layer is readable") — instead of the 409. Both are
refusals and neither writes; case 5 of the new suite pins it so a reader
finds a decision rather than an accident.

## Tests


`packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts`
— five cases: the ordinary duplicate's envelope (`code` + `status` +
both status spellings, never a bare "it threw" — the unfixed producer
threw too), the packaged-set case answering the **lock** (proved by the
lock's own message and by `saves.length === 0`, since ADR-0005's tier
gate answers the same code with a different message), the negative
control that ordinary duplicates WHOSE PROVENANCE RESOLVES did not
become `NOT_OVERRIDABLE` (the corner above is the case that qualifier
excludes), the happy path, and the fail-closed corner.

**Reverse verification — two ablations, each proved on disk and restored
byte-identical to `HEAD`:**

- **A — put the guard back behind the duplicate check.** Mutation landed
(anchor 1 → 0, marker 0 → 1, blob `fed1af15857f` → `3d92cb95990f`);
suite **2 failed / 3 passed** — cases 2 and 5, the two
ordering-dependent ones, and *only* those. Restored: `git diff HEAD`
empty, blob back to `fed1af15857f`.
- **B — drop the `code` stamp.** Mutation landed (target 1 → 0, marker 0
→ 1); suite **2 failed / 3 passed** — cases 1 and 3, `expected undefined
to be 'UNIQUE_VIOLATION'`. Restored: blob back to `7b0914b1749a`.

Both legs ran under a `trap ... EXIT INT TERM` with absolute paths,
restored through `git checkout HEAD -- path`, and verified by `git
hash-object` against the `HEAD` blob rather than by an exit code.

## Verification

Run at final head `72d68b905` unless stated.

- `pnpm --filter @objectstack/plugin-security test` — **116 files / 2240
tests passed**; `typecheck` OK (test layer 0 files / 0 errors).
- `pnpm --filter @objectstack/spec test` — **505 files / 14752 tests
passed**; `typecheck` OK; `check:generated` — all 15 artifacts up to
date.
- `pnpm lint` — the **whole repo**, `eslint . --no-inline-config`,
**exit 0**. Not a narrowed run, so no narrowing needs defending.
- `scripts/pm/dispatch-gates.mjs --ran` — **98 derived families, 98 run,
0 NOT-MEASURED, 0 UNRUN**, each with a recorded exit code, all 0. Two of
them were genuinely red mid-flight and are green only because the fix
landed: `check:engine-double-contract` (the new double's `update` now
opens with `assertEngineUpdateDispatch`, and its pinned seams are
registered) and `check:objectql-double-limit` (the `find` double now
holds the caller's bound by presence).
- Control-character self-scan over every touched file: clean.

Builds and test runs went through `scripts/pm/os-verify-lock.sh`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rd package body stages, and stop the record under-reporting functions (objectstack-ai#19373)

Fixes objectstack-ai#17518

Clause-②: yes

Executes ruling **A′** — decision batch objectstack-ai#192 item 3, comment 5748934194,
maintainer 「192 同意」. Its two steps, its refusals (A and B) and its
fences are followed as written; every place where the tree made me read
the ruling rather than transcribe it is called out below.

Base of every reading in this body: regeneration commit `96dd3549ff6`,
the head of the SIXTH merge.

> ⚠️ **The readings below were brought to this head by the seat, not by
the round that first wrote them.** Two merge rounds have run since the
first draft. Each figure corrected here is named in the correcting
round's own report on card objectstack-ai#17518 — comment 5750725852 for the first,
5750987577 for the second — and the seat re-verified the head, the
regenerated index and mergeability itself before editing. Anything not
listed in those two reports is the original round's reading, unchanged.

## The confidence gap the ruling asked me to close first

「whether `effect` is required or defaulted on the declaration schema —
read it, ⛔ do not mint a value」

**Defaulted.** `FlowFunctionDeclarationSchema.effect` is
`FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT)` where
that constant is `'pure'` (`automation/flow-function.zod.ts`). Measured,
not read off the source alone:
`FlowFunctionLoweredDeclarationSchema.safeParse({ handler: 'x' })`
succeeds and yields `{ handler: 'x', effect: 'pure' }`. The array member
of `functions` states `FlowFunctionEffectSchema.optional()` with **no**
default, so the two forms differ and neither is restated anywhere in
this diff — each JSON stage inherits its form's own optionality by
deriving from it.

That reading is what the producer writes: the bare-callable
normalisation uses `DEFAULT_FLOW_FUNCTION_EFFECT` and the array form
gets nothing.

## What landed

**`packages/spec/src/automation/flow-function.zod.ts`** —
`FlowFunctionLoweredDeclarationSchema` is exported (step 1), with its
`FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a
module-local `const`, and `automation/index.ts`'s `export *` only
re-exports what is already exported.

**`packages/spec/src/stack.zod.ts`** — two new bodies **beside**
`AssembledPackageBodySchema`:

- `ArtifactStagePackageBodySchema` — the on-disk artifact stage.
`functions` entries are the lowered spellings, `hooks[].handler` is a
string.
- `RecordStagePackageBodySchema` — the registry record stage: literally
`ArtifactStagePackageBodySchema.extend({ functions: … })` with
`functions[].handler` optional in both the map-record form and the array
form, and nothing else.

`AssembledPackageBodySchema`, `composeStacks` and the `cannot drift`
invariant are ⛔ untouched: those callables are live on the stage the
assembled body declares itself for, and narrowing it would refuse a
published composition function's own output. Both new schemas carry the
same structural `z.ZodType` annotation as the assembled body, for the
two reasons recorded there (TS7056; a named alias turning `stack.zod`
into a shared chunk).

**`packages/spec/src/api/package-api.zod.ts`** — the installed-package
row's `manifest` is rebound to the record stage (step 1). The
`z.unknown()` override and the docblock defending it are gone, and the
sentence that ruling A step 5 assigns to this edit is corrected in
place: those two members are **not** why `ArtifactPackageSchema` and
`ObjectStackDefinitionSchema` publish no JSON Schema —
`src/stack.zod.ts` is not one of the subpath namespaces
`build-schemas.ts` walks, so neither is ever reached by the emit loop.

**`packages/objectql/src/registry.ts`** — step 2.
`withDeclaredFunctionEntries` rewrites a bare callable `functions` map
entry to `{ handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT }` at the
assembly boundary, before `toRecordManifest` runs. `toRecordManifest`'s
structural rule is ⛔ untouched and no key is special-cased inside the
projection; the two spellings are simply made structurally equal ahead
of it. ⛔ No ref is minted, ⛔ no entry is dropped. The caller's manifest
is never mutated and a copy is made only when an entry really needed
rewriting.

## Two places where I read the ruling rather than transcribed it — both
stated so they can be overruled

1. **「`functions` entries the lowered declaration」 is implemented as
BOTH lowered members of `FlowFunctionEntrySchema`**, not only the record
one. `objectstack build` emits `{ myFn: 'myFn' }` for a bare entry and
`{ myFn: { handler: 'myFn', effect } }` for a declared one, so a stage
admitting only the record form would refuse artifacts this repo really
writes — the failure mode that withdrew letter B, one key across. Ruling
A′'s own step-4 control names both shapes (「a string and a lowered
record」). Measured: the artifact stage accepts a body carrying one of
each.
2. **The array member is transcribed, not derived.** `functions`' array
branch is declared inline inside the assembled body's own shape, and
narrowing it in place is the one thing this pair may not do. The
transcription's drift is guarded instead:
`stack-json-stage-package-body.test.ts` pins the authoring array entry's
key set equal to both JSON stages', so a key added there and not here
reddens by name.

## Acceptance, as ruling A′ lists it

| criterion | result |
|---|---|
| both bodies convert under `z.toJSONSchema` (self-test over the whole
body) | **YES** / **YES**; control: the assembled body still **NO**
(`Function types cannot be represented in JSON Schema`); probe controls
lit `z.string()` YES, dark `z.object({a: z.function()})` NO |
| the showcase-shaped manifest (`config.ts:244-249`) reports **2**
functions on the `GET /packages` row, the bare one as a handler-less
declaration | **2**:
`{"summarizeCompletedTask":{"effect":"pure"},"sweepProjectHealth":{"effect":"writes"}}`,
driven through the real `SchemaRegistry.installPackage` |
| `hooks` unchanged | unchanged: an inline handler is dropped (the key
is optional and admits that), a string handler survives verbatim. The
array `functions` form also keeps its entry:
`[{"name":"syncBilling","effect":"writes"}]` |
| `AssembledPackageBodySchema` / `composeStacks` / the invariant
untouched | untouched — no edit in those regions;
`assembled-package-body.test.ts` and
`compose-stacks-manifest-preserve.test.ts` stay green |
| the two `noted, not filed` corrections in the same edit | baseline
reason line: made TRUE by step 1 rather than reworded —
`automation/FlowFunctionLoweredDeclaration` is now in
`json-schema.manifest/automation.json`, so 「the lowered record …
publishes normally」 is now a fact. `package-api.zod.ts` docblock last
sentence: corrected in place, see above |

Stage separation, measured rather than asserted: the record stage
accepts the handler-less declaration and the **artifact** stage refuses
it; the assembled body accepts a live callable and **both** JSON stages
refuse it; both JSON stages still refuse an authoring glob and an
unknown key (`namesapce`). So the two keys moved from `unknown` to a
declaration, and nothing else moved.

## Reverse verification — two ablations, each restored with proof

Both ran against committed code, each with a `trap` restore, an on-disk
landing proof (anchor `grep -c` before/after plus a blob-hash change)
and a restore proof (`git hash-object` back to the HEAD blob, `git diff
HEAD` empty).

- **A1 — remove the producer normalisation**
(`toRecordManifest(withDeclaredFunctionEntries(manifest))` →
`toRecordManifest(manifest)`; anchor 1→0, injected 1, blob `b0af60d7…` →
`17b7c93c…`): `registry-package-manifest-serializable.test.ts` goes **1
failed / 15 passed**, naming the exact defect — `expected [
'sweepProjectHealth' ] to deeply equal [ 'summarizeCompletedTask', …(1)
]`. Restored blob `b0af60d7…`, diff empty.
- **A3 — collapse the record stage into the artifact stage**
(`jsonStageFunctionsKey(true)` → `(false)`; anchor 1→0, injected 2, blob
`60c13b43…` → `822bed8e…`): **2 failed / 79 passed** across two files —
`record accepts the handler-less declaration; ⛔ the ARTIFACT stage
refuses it` and `parses a row carrying the residual the projection
really produces`. So the one-key difference that IS the fourth stage is
load-bearing in both packages' pins. Restored blob `60c13b43…`, diff
empty.

No ablation is offered for 「both bodies convert」: that claim already
carries its discriminating control inside the same test file (the
assembled body must NOT convert), which is a lit/dark pair rather than
an assertion about itself.

## Tests and gates

All through `scripts/pm/os-verify-lock.sh` with
`OS_VERIFY_LOCK_SLOT=issue-17518`, verdicts read from the wrapper's own
`VERDICT command-exit` line and never a bare `$?`; every exit code
captured before any pipe. Wall-clock figures in the logs are SHARED-BOX
seconds.

- `pnpm --filter @objectstack/spec test` — **513 files / 14971 tests
passed, 1 todo** — the FULL suite, re-run on this head because the sixth
merge carried 128 commits of base movement including breaking spec
changes
- `pnpm --filter @objectstack/objectql test` — **303 files / 5057 tests
passed**
- `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2`
over the package-door / artifact population, enumerated by a name match
on `packages/runtime` for `package` or `artifact` so the population is
reproducible — **39 files / 512 tests passed**. ⚠️ The first attempt
exited 1 in 2 seconds and is recorded as NOT a red: the paths were
repo-root-relative while `pnpm exec` runs at the package root, and the
repo's own guard said so in words (`FILTER SELECTED NOTHING — 39 of the
39 path(s) you named will run no tests`). Re-run with package-relative
paths for the reading above.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — exit 0; both test layers compile (spec **53 files / 257
errors / 142 pins**; objectql **40 / 234 / 65**, unchanged). ⚠️ The spec
ledger moved from 54 / 259 / 144 by main's objectstack-ai#19364 arriving in a merge, ⛔
not by this PR.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — both exit 0 on this head; the debt ledgers held shrink-only
(spec 53 files / 257 errors / 142 pinned signatures; objectql 40 / 234 /
65).
- `pnpm --filter @objectstack/spec build` exit 0 (34/34 declared `.d.ts`
present, `check-dts-references` resolved 378/378), and the whole
`@objectstack/runtime` dependency closure was rebuilt first, so nothing
below read a dist stale against 128 commits of main.

**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived from this tree, every command run
with its exit code written to a file, reconciled with `--ran`: **116
derived, 114 run, 2 NOT-MEASURED, 0 UNRUN**, and the tool's own verdict
line says so. **113 exit 0.** The two NOT-MEASURED are the tool's
DERIVED classification of an exit 3; a third measured nothing too, and
the tool cannot see it because its refusal code is 2. ⛔ None of the
three is a finding:

- `check:dual-build-cjs-loads` — exit **3**, its own `PREREQUISITE NOT
MET … ⛔ This is NOT a pass: nothing was measured` (66 packages have no
`dist`; it wants a whole-repo build).
- `check:type-check-debt` — exit **3**, same shape, same wording, wants
the full package closure built.
- `check-engine-split-ratio --days 90` — exit **2**, refuses on a
shallow clone whose oldest visible commit sits inside the 90-day window.
It says a ratio derived there would be 「real, plausible and WRONG」.

A fourth, `check:skill-examples`, first exited 1 on an unbuilt
`packages/client-react`; after building that package it re-runs
**green** — 258 prose examples type-check across 3 surfaces. Both
readings are stated here, and the reconciliation record carries ONE of
them — the green re-run — because the tool flags a doubly-recorded
family and says to make the record state one thing. The re-derivation on
the final head yields **116** families: `check:api-surface-declarations`
is gone (retired upstream by objectstack-ai#19024 mid-round) and
`check:gitlink-declared` is new, run green. No family is left unrun.

Ratchet families re-run after the last merge, on `96dd3549ff6`:
`check:generated` (all 15 artifacts up to date), `check:api-surface`,
`check:authorable-surface`, `check:export-origins`,
`check:declaration-map`, `check:docs`, `check:skill-refs`,
`check:entry-nameability`, `check:dual-source-exports`,
`check:spec-changes`, `check:spec-parsed-alias`,
`check:published-files`, `check:nul-bytes`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage` — all exit 0. Control characters: `grep
-naP` over every file I hand-edited returns nothing (exit 1).

## Generated artefacts in this diff, and why each moved

- `json-schema.manifest/automation.json`,
`authorable-surface/automation.json`,
`authorable-defaults/automation.json`, `api-surface/*`,
`export-origins/*`, `declaration-map/automation.json`,
`content/docs/references/**` — the new exports, regenerated by the
package's own `gen:` scripts. `authorable-defaults` records
`automation/FlowFunctionLoweredDeclaration:effect = "pure"`, which is
the confidence-gap reading in ledger form.
- `packages/spec/dropped-refinements.baseline.json` — four `api/*`
entries each gain one site (`…manifest.hooks.element.object`), counts
569 → 573. Cause: the record stage **declares** `hooks` where
`z.unknown()` declared nothing, so `HookSchema`'s `object` refinement
now reaches the runtime and not the published file. The ledger is
hand-edited by design and the build printed the exact delta.
- `skills/objectstack-platform/references/_index.md` — one generated
line listing `stack.zod.ts`'s exports.

## `skills/**` readings, and the landing tier

This diff touches `skills/objectstack-platform/references/_index.md`, so
the PR is **governed, Tier H** on its file list. ⛔ It stays a draft and
no AI seat merges, queues or arms auto-merge on it.

Both readings the skills rule requires, at merge base `c334ba0f3a6`:

- **changed file, whole file**: 41 lines before, 41 after — net **0**.
The diff is one regenerated line.
- **package total (sum of every `SKILL.md`)**: 6145 before, 6145 after —
net **0**.

`node scripts/check-skills-token-ratchet.mjs` exits 0 and classifies
this file as **generator-owned (measured, not ratcheted)**, so no
authored ceiling is charged.

## Clause ②, and the changeset is not one package's

`Clause-②: yes`, and two changesets because two published packages move:

- `@objectstack/spec` — **minor**. New exports, and the two
installed-package responses move from `z.unknown()` on `functions` /
`hooks` to declared JSON shapes. That is a narrowing on a published
declaration; what it does NOT withdraw is measured, on real producers:
the showcase shape, the array form and the already-lowered body an
artifact boot installs all parse.
- `@objectstack/objectql` — **patch**. `GET /packages` reports functions
it previously dropped. No API is added or removed; a read door stops
under-reporting. Grade it up if a payload gaining entries reads as minor
to the reviewer.

## Serial and merge state, re-taken by this seat

Changed-file map re-taken first-hand over all **33** open PRs (271 file
rows) rather than inherited. LIT control
`packages/spec/src/ui/action-params.zod.ts` resolves to objectstack-ai#19315; DARK
control `packages/spec/src/zzz-no-such.zod.ts` resolves to nothing.

- `packages/spec/src/automation/flow-function.zod.ts`,
`packages/spec/src/api/package-api.zod.ts`,
`packages/objectql/src/registry.ts` — **free**.
- `packages/spec/src/stack.zod.ts` — held by objectstack-ai#18482, objectstack-ai#19147, objectstack-ai#19314, all
below A′'s region. objectstack-ai#19147 landed during this round and merged cleanly
here (its `stack.zod.ts` hunk is a comment).
- `packages/spec/dropped-refinements.baseline.json` — also written by
objectstack-ai#19147 (landed, resolved here) and by the still-open **objectstack-ai#19335**, which
rewrites the same `measured` header and adds entries. That is a
line-level contention on a ledger whose correct value is recomputable:
whoever lands second re-runs `pnpm --filter @objectstack/spec build` and
re-applies the delta it prints. ⛔ Not a semantic collision.

`origin/main` has been merged **six** times on this branch. `objectstack-ai#19024`
(which retired `api-surface-declarations/`) came in early, which is why
no `api-surface-declarations/*.txt` appears in this diff. The fifth
merge brought **objectstack-ai#19363**, a BREAKING spec change. The **sixth** merge,
the head of this body, brought **128 commits** — so the full spec suite
was re-run rather than only the generated gates.

⛔ `scripts/pm/os-regen-merge.sh` was NOT used in either round — its
`rerun` arm is re-entrant and commits a revert of the operator's own
regeneration, filed as **objectstack-ai#19392**. Steps 1–3 of its documented order
were performed by hand, against a merge base captured BEFORE the merge
and an `origin/main` fetched into an OWNED ref so a sibling's fetch
could not move the target mid-round.

**The sixth merge decided THREE paths, and only one of them was a
conflict.** That gap is worth stating, because resolving only what a
conflict probe names would have landed a silent loss:

| path | routed | what the merge did | how it was resolved |
|:--|:--|:--|:--|
| `content/docs/references/index.mdx` | `merge=os-regen` | driver
deferred it, exit 0 — **main's side silently dropped** (merged blob
`6290447bd9a` == ours, != theirs `7e1f9b6f13e`) | main's side restored
into the WORKING TREE ONLY, then regenerated whole |
| `content/docs/references/api/package-api.mdx` | `merge=os-regen` |
same — **main's side silently dropped** (merged `988bedaa480` == ours,
!= theirs `d09cd420711`) | same |
| `packages/spec/dropped-refinements.baseline.json` | **not** routed |
exit 1 — the only real text conflict, one hunk, confined to three
summary counters in the `measured` header | both sides' entries unioned,
then the build adjudicated |

⚠️ **`package-api.mdx` appears in NO conflict list and never could.** It
text-merges cleanly driver-free, so a GitHub-condition probe cannot name
it; only the both-edited ROUTED set, computed per file against the
pre-merge base, finds it — which is exactly what `os-regen-merge.sh`
step 2 specifies and what the driver's own `$GIT_DIR/os-regen-pending`
record listed.

**The regenerated docs are the UNION, proven in both directions**
(added/removed line multisets compared as sets): `package-api.mdx`
identical at 20 and 14 lines; `index.mdx` identical at 12 and 6 lines,
excluding the two running-total lines — a union MUST move a total
neither side moves alone, so their disagreement is the signature of a
correct union rather than a failure, and the line counts already matched
(16/16, 10/10) before excluding them. The total is **re-derived, not
arithmetic**: base 1533, this branch alone 1534, main alone 1534, merged
tree **1535**, and 1535 is what `gen:schema` itself reports for the
merged sources. Main brought `DatasetSelection`, `DatasetCompareTo` and
`DatasetTotals` and retired `KernelSecurityScanResult` /
`KernelSecurityVulnerability`; this branch brought
`FlowFunctionLoweredDeclaration`. All survive, asserted through the
published export map of the freshly built dist with a dark control (an
invented export name reads undefined).

**The ledger was resolved by hand, and that is the only route
available.** `dropped-refinements.baseline.json` is hand-edited BY
DESIGN with no `gen:` script — its own description states why: *"a
generator would let a new gap be admitted by running a command instead
of by a decision, which is the silence this ledger exists to end."* The
build VALIDATES it bidirectionally and refuses; it never writes it. Both
sides' entries were unioned (union keys missing from the merged file:
**none**; merged keys not in the union: **none**; `api/DatasetSelection`
arrived from main via objectstack-ai#19638 and survives; main's removal of the
`fields.out.keyType` sites is kept — **nine** site lines at the merge
base, zero at this head and zero on main (lit control: 204 `"sites"`
keys at base; dark control 0). ⚠️ The merge round's own prose said
*five*; that was a narrative miscount caught by the merge-delta review
and re-counted by the seat. The FILE was always right), then
`gen:schema` adjudicated and measured 565 dropped sites across 205
published schemas — the union as resolved. One counter the build
corrected: `refinementSitesThatDidProject` read 357 and the build
measures 366.

⚠️ **That correction is filed as objectstack-ai#19681**, because nothing in the
repository would have caught it: two of the four `measured` counters
have no reader anywhere (lit control — the other two have two readers
each, dark control 0), so they can hold any number and every gate stays
green.

## Acceptance notes

- **noted, not filed**: regenerating
`packages/spec/api-surface-declarations/ui.txt` produced a 184-line
change that is a pure permutation of its own content — the same union
members in a different order, `0 removed, 0 added, 35 reshaped`.
Verified as a precedented shape rather than a defect: commit
`24d622b94b8`, a spec change touching **zero** files under
`packages/spec/src/ui/`, moved the same file by 5 lines whose sorted
content is byte-identical. The whole artefact was retired upstream by
objectstack-ai#19024 mid-round, so nothing of it survives in this diff and the
population is gone. **Carrier: none — the file no longer exists.**
- **noted, not filed**: `packages/objectql`'s tests resolve
`@objectstack/metadata-protocol` from `dist`, so after merging upstream
objectstack-ai#19277 the seven assertions in
`protocol-install-package-enable-on-install.test.ts` failed against a
stale build of a package this PR never touches; building that one
package turns all seven green. A local-environment reading, not a repo
defect, and `check:test-source-alias` already owns the aliased/unaliased
ledger this sits in. **Carrier: the next seat that runs objectql's suite
after a merge — it will see the same red and should build the dependency
before reading it as a finding.**

## 维护者速读(草稿)

**改了什么** —— 一个包的「包体」在平台里其实要经过四个阶段:作者写的、内存里装配好的、落盘成 artifact
的、注册表记录下来的。前两个早有声明,后两个从来没有。这次把后两个补上:`ArtifactStagePackageBodySchema`(落盘
artifact)和
`RecordStagePackageBodySchema`(注册表记录),放在既有的装配体**旁边**,装配体一个字不动。同时修好一个生产者缺陷:`GET
/packages` 以前会把「裸写的函数」整条漏报,现在两种写法都报。

**为什么改** —— 两件事各有代价。其一,装配体里有两个键(`functions`、`hooks`)声明了「可以是一个活的函数」,而
JSON Schema 表达不了函数,于是**任何嵌入它的接口都会整份丢掉自己的 JSON Schema**;读 API
只能把这两个键写成「什么都收、不检查」。其二,我们自己发布的 showcase 声明了 2 个函数,而 `GET /packages` 只报 1
个——机器可读的读门把事实说少了。

**风险与代价(含回滚)** ——
风险集中在一处:那两个键从「什么都收」变成「按声明收」,理论上可能拒掉今天能读的行。已实测三种真实生产者(showcase
的写法、数组写法、artifact 启动装回来的写法)全部照常通过,并且用两次消融证明了这些断言真的会红而不是摆设。⛔ 装配体与
`composeStacks` 未动,所以 `os dev` / `os serve` 的行为不受影响——这正是上一版裁决 B
被撤回的原因,这次没有重蹈。回滚:两个 spec 改动与 objectql 改动互相独立,`git revert`
任一半都不会让另一半变红;最小回滚是把 `package-api.zod.ts` 的那一行绑回装配体,新声明留着不用。

**席位意见** ——

**你要做的** —— 这个 PR 的文件里有一份 `skills/**` 的生成文件,按规则整单属于 Tier
H,**只有你(或你授权的批准)能让它落地**;AI 席位不会合并、不会排队、不会解除 draft。请看两点:①
`@objectstack/objectql` 我打的是 `patch`,理由是「读门修复、不增删 API」,若你认为「载荷多出条目」应算
minor,说一声即可改;② `functions` 的声明式阶段我按「两种 lowered 写法都收」实现(理由写在上面第 1
条),如果裁决本意是只收记录式那一种,也请直接说,那会让 `objectstack build` 今天写出的一种 artifact 被拒。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation domain:spec priority:p1 High: required for production / M2 size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ruling C] revert PR #18971 — the 12 MiB declaration-text snapshot comes out; consumer compilation against spec@main becomes the shape gate

7 participants